React Router v7 in Practice: Lazy Loading, Nested Routes, and the SPA Routing Playbook
1. Project Background and Tech Stack
This is a React project created with the Vite scaffolding tool. Core dependencies:
{
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.2"
}
}
React 19 + React Router v7 is the latest front-end routing tech stack.
2. Entry Point: Everything Starts with createRoot
// src/main.jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
The entry point for React 19 is written the same way as React 18: use createRoot to mount to the #root node, wrapping it with StrictMode (a helper tool that identifies potential issues during development). The real content is all inside <App />.
3. Core Routing Configuration: App.jsx Panorama
This is the core of the entire application, packed with information. We'll break it down block by block.
// src/App.jsx
import { lazy, Suspense } from 'react';
import {
HashRouter as Router,
Routes,
Route,
Navigate
} from 'react-router-dom';
import Navigation from './components/Navigation';
// Route lazy loading
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const UserProfile = lazy(() => import('./pages/UserProfile'));
const NotFound = lazy(() => import('./pages/NotFound'));
const Products = lazy(() => import('./pages/Products'));
const ProductDetail = lazy(() => import('./pages/Products/Detail'));
const NewProduct = lazy(() => import('./pages/Products/New'));
const App = () => {
return (
<>
<Suspense fallback={<div>Loading...</div>}>
<Router>
<Navigation />
<div id="container">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/user/:id" element={<UserProfile />} />
<Route path="*" element={<NotFound />} />
<Route path="/products" element={<Products />}>
<Route path=":productId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
<Route path="/old-path" element={
<Navigate replace to="/new-path" />
} />
</Routes>
</div>
</Router>
</Suspense>
</>
)
}
export default App
3.1 HashRouter: The Routing "Shell"
import { HashRouter as Router } from 'react-router-dom';
<Router>
{/* All routing-related content goes inside here */}
</Router>
HashRouter is the root container for routing. It uses the #/ hash format to manage URLs (e.g., localhost:5173/#/about).
Why use HashRouter instead of BrowserRouter? Because hash routing requires no server configuration; it runs purely on the front end, suitable for simple development and deployment scenarios. If you use BrowserRouter (History mode), the URL is cleaner (localhost:5173/about), but the server needs a fallback rule configured, otherwise a refresh will result in a 404.
The underlying principle is exactly the hashchange event discussed in the previous article—React Router just wraps it for you in a component-based way.
3.2 Route Lazy Loading: Code Splitting with lazy + Suspense
import { lazy, Suspense } from 'react';
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
// ...
<Suspense fallback={<div>Loading...</div>}>
<Router>
{/* ... */}
</Router>
</Suspense>
This is a key technique for SPA performance optimization.
Without lazy loading, all page code gets bundled into one giant JS file. When a user first opens the homepage, they must download the code for About, Products, UserProfile, and every other page, severely slowing down the first screen load time.
lazy() turns each page into an independent code chunk, loaded on demand only when the user actually navigates to that route. Suspense's fallback is the placeholder UI shown during loading.
User visits homepage → Downloads only Home.js
User clicks About → Downloads About.js on demand (shows Loading...)
User clicks Products → Downloads Products.js on demand
This is the essence of a SPA: download and execute only for the current page.
3.3 Routes and Route: Declarative Routing Configuration
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/user/:id" element={<UserProfile />} />
<Route path="*" element={<NotFound />} />
{/* ... */}
</Routes>
Routes is the container for routing configuration, holding a set of Route components. React Router's design philosophy is everything is a component—the routing configuration itself is JSX, serving as both the declaration of "where" and the rendering of "where".
Key rule: Inside Routes, only one Route matches and renders at a time. Whichever path matches the current URL determines which component is displayed.
3.4 Dynamic Route Parameters: :id
<Route path="/user/:id" element={<UserProfile />} />
:id is a dynamic parameter placeholder. When a user visits /#/user/666, 666 is captured as the id parameter.
Retrieve it inside the component using useParams:
// src/pages/UserProfile/index.jsx
import { useParams } from 'react-router-dom'
function UserProfile() {
let { id } = useParams();
console.log(id); // "666"
return (
<>
<h1>user Profile: {id}</h1>
</>
)
}
useParams is a Hook that returns an object containing all path parameters. This reflects React's Hooks philosophy—"hooking" routing state into function components without passing it down through props layer by layer.
3.5 Nested Routes and Outlet: Multi-level Routing
This is one of React Router's most powerful features.
<Route path="/products" element={<Products />}>
<Route path=":productId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
The parent route /products renders the <Products /> component. Child routes mark their rendering outlet inside the parent component using <Outlet />:
// src/pages/Products/index.jsx
import { Outlet } from 'react-router-dom';
function Products() {
return (
<>
<h1>Products</h1>
<Outlet /> {/* Child route content renders here */}
</>
)
}
URL correspondence:
| URL | Rendered Result |
|---|---|
/#/products |
Products (Outlet is empty) |
/#/products/123 |
Products + ProductDetail (productId=123) |
/#/products/new |
Products + NewProduct |
The essence of nested routing is: the parent component's layout (navigation, sidebar, etc.) stays unchanged, and only the Outlet area switches content. This is much more elegant than manually managing page transitions.
3.6 Programmatic Navigation: useNavigate
// src/pages/NotFound/index.jsx
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
const NotFound = () => {
let navigate = useNavigate();
useEffect(() => {
setTimeout(() => {
navigate('/'); // Redirect to homepage after 3 seconds
}, 3000)
}, [])
return(
<>
<h1>404 Not Found</h1>
</>
)
}
useNavigate returns a navigate function that can trigger route transitions in code. Common scenarios:
- Redirect after form submission
- Redirect after permission check failure
- Countdown auto-redirect (like this example's 404 → homepage)
Note that window.location.href = '/' is not used here (which would cause a full page refresh). Instead, navigate('/') is used (a pure front-end route transition, no refresh). This is the fundamental difference between SPA navigation and traditional navigation.
3.7 Redirect: The Navigate Component
<Route path="/old-path" element={
<Navigate replace to="/new-path" />
} />
<Navigate> is a declarative redirect component. When a user visits /old-path, they are automatically redirected to /new-path. The replace attribute indicates using history.replaceState instead of pushState—replacing the current history entry rather than adding a new one, so the user won't return to /old-path when clicking "back".
Applicable scenarios: old URL migration, redirecting unauthenticated users in permission guards.
3.8 404 Fallback: The Wildcard Route
<Route path="*" element={<NotFound />} />
* is a greedy match that catches all unmatched paths. Placing it at the end of Routes creates a 404 fallback. No matter what messy URL a user enters, they won't see a blank screen; instead, they'll see a 404 page and be automatically redirected back to the homepage.
4. Navigation Component: Using Link Instead of a Tags
// src/components/Navigation.jsx
import { Link } from 'react-router-dom';
function Navigation() {
return (
<nav>
<ul>
<li><Link to="/">Home</Link></li>
<li><Link to="/about">About</Link></li>
<li><Link to="/user/666">Xiao Jia</Link></li>
<li><Link to="/products">Products</Link></li>
<li><Link to="/products/123">ProductDetail</Link></li>
<li><Link to="/products/new">Add Product</Link></li>
</ul>
</nav>
);
}
Why not just use <a href="#/about">?
Because a native <a> tag click triggers the browser's default navigation behavior. React Router's <Link> component intercepts the click event internally and uses pushState / hash changes to achieve SPA-style refreshless navigation. The to attribute specifies the target path, making the semantics clear and facilitating framework-level optimizations like preloading.
5. Routing Capability Panorama
Summarizing all the usages above, a complete React Router application includes these core capabilities:
| Capability | API | Purpose |
|---|---|---|
| Routing Container | <HashRouter> / <BrowserRouter> |
Manage global routing state |
| Route Configuration | <Routes> + <Route> |
Declare URL → Component mapping |
| Lazy Loading | lazy() + <Suspense> |
On-demand loading, optimize first screen |
| Dynamic Parameters | :id + useParams() |
Extract variables from URL |
| Nested Routes | Child <Route> + <Outlet /> |
Multi-level routing, layout reuse |
| Declarative Navigation | <Link to="..."> |
Refreshless navigation |
| Programmatic Navigation | useNavigate() |
Trigger route transitions in code |
| Redirect | <Navigate replace to="..."> |
URL migration, permission guards |
| 404 Fallback | path="*" |
Fallback page for unmatched routes |
6. From Principle to Framework: Connecting Two Articles in One Sentence
If you've read the previous article "From Multi-Page to SPA: Front-End Hash Routing Principles", you'll find that what React Router does is essentially identical to a hand-written HashRouter:
Native hashchange event
↓ Wrapped into components
<HashRouter> listens for hash changes
↓ Declarative configuration
<Routes><Route> routing table
↓ On-demand loading
lazy + Suspense code splitting
↓ Rendering outlet
#container → <Outlet />
Frameworks have no magic; they simply reorganize plain native APIs in a component-based way. Understanding the underlying hashchange mechanism means all of React Router's APIs are just different expressions of it.
Learn principles so you aren't intimidated by frameworks; learn frameworks so you don't reinvent the wheel. Combining both is what constitutes complete front-end capability.