React Router v7 From Scratch: Lazy Loading, Auth Guards, and Nested Routes
Foreword
In the previous article "From Multi-Page to SPA: Writing a Hash Router by Hand", we hand-wrote a Hash router and understood the essence of front-end routing. But in actual projects, we don't build wheels from scratch every time—React Router is the most mainstream front-end routing solution in the React ecosystem.
This article is based on React Router v7 (the latest version). Through a complete demo project, it covers all high-frequency scenarios in real projects, from basic configuration, dynamic routing, nested routing, and lazy loading, to authentication guards and 404 fallbacks.
1. Project Start
Tech Stack
| Tool | Version | Purpose |
|---|---|---|
| Vite | ^8.1.1 | Build tool |
| React | ^19.2.7 | UI framework |
| react-router-dom | ^7.18.2 | Front-end routing |
Entry File
// src/main.jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
The entry point is as concise as ever—because all routing logic is encapsulated in App.jsx.
2. HashRouter vs BrowserRouter: Two Routing Modes
Before officially starting, an important choice must be clarified:
HashRouter: http://localhost:5173/#/pay
BrowserRouter: http://localhost:5173/pay
| HashRouter | BrowserRouter | |
|---|---|---|
| URL Appearance | Contains #, a bit "ugly" |
Clean, same as traditional URLs |
| Principle | Listens to hashchange event |
Based on HTML5 History API (pushState/popState) |
| Server Support | Not required | Requires server configuration (all paths return index.html) |
| SEO | Poor (hash is not indexed by search engines) | Good |
| Applicable Scenarios | Simple demos, backend systems not needing SEO | Most production projects |
This article's demo uses BrowserRouter, the mainstream choice for modern SPAs.
3. Basic Route Configuration: Routes + Route
The core configuration pattern of React Router v7 is component-based configuration—routes are components, configuration is declaration.
// src/App.jsx (Core Skeleton)
import {
BrowserRouter as Router,
Routes,
Route,
} from 'react-router-dom'
const App = () => {
return (
<Router>
<Navigation />
<div id="container">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Routes>
</div>
</Router>
)
}
Key Points
<Router>: The outermost wrapper, the routing container for the entire application (BrowserRouteris used here)<Routes>: The route matching area, one and only one<Route>will be rendered<Route>: A single routing rule,pathmatches the URL,elementspecifies the component to renderpath="*": A wildcard that matches all unmatched paths, used for 404 fallback
Note:
<Navigation />is placed outside<Routes>, meaning it is displayed on all pages—this is the "common area" (header/navigation bar) of an SPA.
4. Link Component: SPA Navigation Method
Traditional <a href="/about"> triggers a full browser refresh, which is disastrous in an SPA.
React Router provides the <Link> component—it intercepts the click event at a low level, only updating the URL and rendering content, without refreshing the page.
// 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/123">Xiao Jia</Link></li>
<li><Link to="/products/123">Product Detail</Link></li>
<li><Link to="/products/new">New Product</Link></li>
<li><Link to="/pay">Pay</Link></li>
</ul>
</nav>
);
}
<Link> essentially still renders an <a> tag, but it takes over the click behavior, calling history.pushState to change the URL.
5. Route Lazy Loading: Make the Home Page Fly
If all page components are loaded at once on the home page, the JS bundle will be very large, and the home page loading speed will be worrying.
React.lazy + Suspense implements "on-demand loading"—the code for a corresponding component is only downloaded when a specific route is visited.
import { lazy, Suspense } from 'react';
// ❌ Traditional way: Load everything on the home page
// import Home from './pages/Home';
// import About from './pages/About';
// ✅ Lazy loading: Download only when visited
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 App = () => {
return (
<Router>
{/* Suspense is the "loading" fallback for lazy loading */}
<Suspense fallback={<div>Loading...</div>}>
<Navigation />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
{/* ... */}
</Routes>
</Suspense>
</Router>
)
}
Execution Flow
User visits /about
→ about's JS chunk hasn't been downloaded yet
→ Show <Suspense fallback> (Loading...)
→ Async download completes
→ Render <About />
Open the browser DevTools Network panel, and you will see that each page is a separate JS file, loaded only on the first visit.
6. Dynamic Routing: The Parameter Magic of /user/:id
The same page template displays different content based on different parameters in the URL—this is dynamic routing.
{/* Configuration */}
<Route path="/user/:id" element={<UserProfile />} />
// src/pages/UserProfile/index.jsx
import { useParams } from 'react-router-dom';
function UserProfile() {
let { id } = useParams(); // Extract URL parameters
return (
<h2>User Profile: {id}</h2>
)
}
| Visited URL | useParams() Result |
|---|---|
/user/123 |
{ id: "123" } |
/user/xiaojia |
{ id: "xiaojia" } |
useParams is one of the most commonly used Hooks in React Router—call it on demand, use it immediately.
7. Nested Routing: Elegant Organization of Parent-Child Pages
In real projects, pages often have hierarchical relationships. For example, /products is the product list, /products/123 is the product detail, and /products/new is for adding a new product.
{/* Nested Route Configuration */}
<Route path="/products" element={<Products />}>
<Route path=":productId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
Note: The
pathof child routes is a relative path; there's no need to write/products/:productId.
// src/pages/Products/index.jsx
import { Outlet } from 'react-router-dom';
const Products = () => {
return (
<>
<h1>Product List</h1>
{/* Outlet is the "slot" for child routes; child components render here */}
<Outlet />
</>
)
}
Rendering Results
Visit /products → Product List (Outlet is empty)
Visit /products/123 → Product List + Product Detail 123
Visit /products/new → Product List + New Product
<Outlet /> is the "placeholder" for nested routing—the parent component determines the common layout, and the child component fills the variable area.
8. Redirects: The Navigate Component
During project iteration, some old URLs need to jump to new addresses; or unauthenticated users need to be redirected to a login page.
{/* Redirect: /old-path → /new-path */}
<Route path="/old-path" element={
<Navigate replace to="/new-path" />
} />
| Attribute | Purpose |
|---|---|
to |
Target path |
replace |
When true, replaces the current history entry with the new one (user cannot "go back" to it) |
replace is especially important in login scenarios—after a successful login, the user shouldn't be able to "go back" to the login page.
9. Authenticated Routes: ProtectRoute Route Guard
This is the most common requirement in enterprise projects—certain pages require login to access.
// src/App.jsx
<Route path="/pay" element={
<ProtectRoute>
<Pay />
</ProtectRoute>
} />
// src/ProtectRoute.jsx
import { Navigate } from 'react-router-dom';
const ProtectRoute = ({ children }) => {
const isLogin = localStorage.getItem('isLogin') === 'true';
if (!isLogin) {
// Not logged in → Redirect to login page, and remember "where they came from"
return (
<Navigate
to="/login"
replace
state={{ from: location.pathname }}
/>
);
}
// Logged in → Allow access, render child components
return <>{children}</>;
}
Core Design Pattern: children Slot
<ProtectRoute>
<Pay /> ← This is children
</ProtectRoute>
const ProtectRoute = ({ children }) => {
// children is <Pay />
// Authenticated: Render {children}
// Not authenticated: Render <Navigate> (do not render children)
}
This is the essence of React componentization—children gives a component the ability to "wrap" other components, analogous to a Modal component:
<Modal> ← Mask layer + window frame
<form>...</form> ← children custom content
</Modal>
10. Login Flow: useNavigate + useLocation + state
After the authentication route intercepts and redirects to /login, it passes state to record the source path—automatically jumping back after a successful login.
// src/pages/Login/index.jsx
import { useNavigate, useLocation } from 'react-router-dom';
const Login = () => {
const navigate = useNavigate();
const location = useLocation();
// Extract "where they came from" from state, default to home page if none
const from = location.state?.from || "/";
function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const username = formData.get("username");
const password = formData.get("password");
if (username === 'admin' && password === '123456') {
localStorage.setItem('isLogin', 'true');
// replace: true — prevents user from going back to the login page
navigate(from, { replace: true });
}
}
return (
<form onSubmit={handleSubmit}>
<input name="username" placeholder="Please enter username" />
<input name="password" placeholder="Please enter password" />
<button type="submit">Login</button>
</form>
)
}
Full Login Flow Chain
1. Unauthenticated user visits /pay
2. ProtectRoute detects isLogin=false
3. <Navigate to="/login" state={{ from: "/pay" }} />
4. User enters credentials on /login and submits
5. Login successful, localStorage.setItem('isLogin', 'true')
6. navigate("/pay", { replace: true })
— Jump back to /pay, and /login won't remain in browser history
?.is the optional chaining operator from ES11.location.state?.fromsafely reads potentially non-existent nested properties, avoidingCannot read property 'from' of undefined.
11. 404 Page: The Art of Wildcard Route Fallback
{/* * is placed last, catching all unmatched paths */}
<Route path="*" element={<NotFound />} />
// src/pages/NotFound/index.jsx
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
const NotFound = () => {
let navigate = useNavigate();
useEffect(() => {
// Automatically jump back to home page after 3 seconds
setTimeout(() => navigate('/'), 3000);
}, []);
return <h1>Not Found</h1>;
}
Note the order:
path="*"must be placed at the end of<Routes>. React Router matches in order;*at the front would swallow all routes.
12. Complete Routing Architecture Overview
Stringing together all the knowledge points above, this is the complete routing tree for this project:
<Router> // Routing container
<Suspense fallback={Loading}> // Lazy loading fallback
<Navigation /> // Global navigation (unaffected by route switching)
<Routes> // Route matching area
/ → Home
/about → About
/user/:id → UserProfile (Dynamic routing)
/products → Products + Outlet (Nested routing)
/products/:productId → Detail
/products/new → NewProduct
/old-path → Navigate → /new-path (Redirect)
/login → Login (Login page)
/pay → ProtectRoute → Pay (Auth guard)
* → NotFound (404 fallback)
</Routes>
</Suspense>
</Router>
13. Summary: Understanding React Router in One Diagram
┌─────────────────────────────────────────────────┐
│ <Router> │
│ ┌──────────┐ ┌──────────────────────────────┐ │
│ │Navigation│ │ <Routes> │ │
│ │ (Always │ │ path="/" → <Home /> │ │
│ │ shown) │ │ path="/about"→ <About /> │ │
│ │ │ │ path="/user/:id" → Dynamic │ │
│ │ <Link> │ │ path="/products" → Nested+ │ │
│ │ <Link> │ │ Outlet │ │
│ │ <Link> │ │ path="/pay" → Auth Guard │ │
│ │ │ │ path="*" → 404 Fallback │ │
│ └──────────┘ └──────────────────────────────┘ │
└─────────────────────────────────────────────────┘
| Concept | One-Liner Summary |
|---|---|
| BrowserRouter | Based on History API, clean URLs, preferred for production projects |
| Routes + Route | Component-based configuration, declaration is routing |
| Link | SPA's replacement for <a> tag, no page refresh |
| lazy + Suspense | On-demand loading of page code, optimizes first-screen speed |
| useParams | Extracts dynamic parameters :id from the URL |
| Outlet | "Slot" for child components in nested routing |
| Navigate | Declarative redirect |
| useNavigate | Imperative navigation (called in event handlers) |
| useLocation | Gets the current URL and routing state |
| ProtectRoute | Children pattern to implement authentication guard |
| path="*" | Wildcard fallback for 404 |
Next Steps
If you came from the previous Hash routing article, you should now clearly see the trajectory of technological evolution:
Native hashchange event
↓
Hand-written HashRouter class
↓
React Router (hash mode)
↓
React Router (history mode) + Lazy loading + Authentication + Nested routing
Next steps for deeper learning:
- React Router Source Code: How is its route matching algorithm implemented?
- SSR Routing: File-system routing in Next.js
- State Management Integration: Cooperation between zustand + React Router
If this article was helpful to you, feel free to like, bookmark, and comment🎉 Your support is my motivation to keep producing!