React Router v6 — nested routes and guards
Ever built an order management dashboard where clicking a customer shows only their orders while keeping the main navigation? Or needed to lock certain pages behind a login? React Router v6 makes this smooth with nested routes and route guards.
This guide builds a complete order management app. We'll use authentication, customer dashboards, protected routes, and nested navigation—all tied to real-world order management scenarios from our previous articles.
Ready to build? Let's go!
🚀 Quick Start (2 minutes)
npx create-react-app order-router-app
cd order-router-app
npm install react-router-dom@6
npm startReplace these 4 files and you're live:
src/App.js (Main router setup)
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './AuthContext';
import LoginPage from './LoginPage';
import Dashboard from './Dashboard';
import ProtectedRoute from './ProtectedRoute';
import CustomerOrders from './CustomerOrders';
import './App.css';
function App() {
return (
<Router>
<div className="App">
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/customers/:customerId" element={<ProtectedRoute><CustomerOrders /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</AuthProvider>
</div>
</Router>
);
}
export default App;src/App.css (Clean styling)
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
.App { max-width: 900px; margin: 0 auto; padding: 20px; }
nav { background: #f5f5f5; padding: 15px; border-radius: 8px; margin-bottom: 20px; }
nav a { margin-right: 15px; text-decoration: none; color: #0066cc; font-weight: 500; }
nav a.active { color: #d63384; font-weight: bold; }
nav a:hover { text-decoration: underline; }
.list { list-style: none; padding: 0; }
.list li {
padding: 15px;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
form {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
max-width: 400px;
}
input, button, select {
width: 100%;
padding: 12px;
margin-bottom: 15px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 16px;
}
button {
background: #0066cc;
color: white;
border: none;
cursor: pointer;
font-weight: 500;
}
button:hover { background: #0052a3; }
.error { color: #d63384; background: #ffe6e6; padding: 10px; border-radius: 6px; margin: 10px 0; }
.success { background: #e6ffe6; color: #006600; padding: 10px; border-radius: 6px; }
.loading { text-align: center; padding: 40px; font-style: italic; color: #666; }
h1 { color: #333; }
h2, h3 { color: #444; }Username: admin (no password needed!)
src/NewOrder.js
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
function NewOrder() {
const [formData, setFormData] = useState({
customer: '',
amount: '',
items: '',
status: 'pending'
});
const navigate = useNavigate();
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
const handleSubmit = (e) => {
e.preventDefault();
// Simulate save
console.log('New order:', formData);
alert('✅ Order created! Check console.');
navigate('/'); // Back to orders list
};
return (
<div>
<h2>➕ Create New Order</h2>
<form onSubmit={handleSubmit}>
<input
name="customer"
value={formData.customer}
onChange={handleChange}
placeholder="Customer Name"
required
/>
<input
name="amount"
type="number"
value={formData.amount}
onChange={handleChange}
placeholder="Order Amount"
required
/>
<input
name="items"
type="number"
value={formData.items}
onChange={handleChange}
placeholder="Number of Items"
required
/>
<select name="status" value={formData.status} onChange={handleChange}>
<option value="pending">Pending</option>
<option value="shipped">Shipped</option>
</select>
<div>
<button type="submit">💾 Save Order</button>
<button type="button" onClick={() => navigate('/')}>
← Cancel
</button>
</div>
</form>
</div>
);
}
export default NewOrder;🏠 Nested Routes: The Magic Layout
Problem: You want a dashboard layout (nav + header) that shows different content based on the URL.
Solution: <Outlet /> renders nested routes inside parent components.
src/Dashboard.js (Parent layout with nested content)
import React from 'react';
import { Outlet, NavLink, useNavigate, Routes, Route } from 'react-router-dom'; // 👈 Add Routes, Route
import OrderList from './OrderList'; // 👈 Import OrderList
import NewOrder from './NewOrder'; // 👈 New import
import { useAuth } from './AuthContext';
function Dashboard() {
const { user, logout } = useAuth();
const navigate = useNavigate();
return (
<div>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 30 }}>
<h1>🛒 Order Management Dashboard</h1>
<div>
<span style={{ marginRight: 15, fontWeight: 'bold' }}>👤 {user.username}</span>
<button onClick={logout}>Logout</button>
</div>
</header>
<nav>
<NavLink to="/" end>All Orders</NavLink> {/* 👈 'end' for exact match */}
<NavLink to="/orders/new" end>New Order</NavLink> {/* 👈 Now works! */}
<NavLink to="/customers/AcmeCorp" end>Acme Corp</NavLink>
<NavLink to="/customers/BetaInc" end>Beta Inc</NavLink>
</nav>
<main style={{ minHeight: '400px' }}>
<Routes> {/* 👈 Nested routes here */}
<Route index element={<OrderList />} /> {/* / → OrderList */}
<Route path="orders/new" element={<NewOrder />} /> {/* /orders/new → NewOrder */}
</Routes>
</main>
</div>
);
}
export default Dashboard;Try it: Login → Dashboard → Click "Acme Corp". Same header/nav, different content!
🔐 Route Guards: Lock Your Pages
Protect routes with a simple wrapper component. Builds on our controlled inputs and props vs state.
src/AuthContext.js (Global auth state)
import React, { createContext, useContext, useState } from 'react';
const AuthContext = createContext();
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const login = (username) => {
setUser({ username, role: 'admin' });
};
const logout = () => {
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);src/ProtectedRoute.js (The guard!)
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from './AuthContext';
function ProtectedRoute({ children }) {
const { user } = useAuth();
const location = useLocation();
// No user? Send to login, remember where they wanted to go
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
export default ProtectedRoute;src/LoginPage.js (Smart login form)
import React, { useState } from 'react';
import { useAuth } from './AuthContext';
import { useNavigate, useLocation } from 'react-router-dom';
function LoginPage() {
const [username, setUsername] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || '/';
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 800));
if (username.trim() === 'admin') {
login(username);
navigate(from, { replace: true });
} else {
setError('❌ Invalid credentials. Try "admin"');
}
setLoading(false);
};
return (
<div style={{ maxWidth: '400px', margin: '100px auto' }}>
<h2 style={{ textAlign: 'center' }}>🔐 Login to Order System</h2>
<form onSubmit={handleSubmit}>
<div>
<label>Username:</label>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
type="text"
placeholder="Enter 'admin'"
disabled={loading}
/>
</div>
{error && <div className="error">{error}</div>}
<button type="submit" disabled={loading || !username.trim()}>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
<p style={{ textAlign: 'center', marginTop: 20, fontSize: '14px', color: '#666' }}>
Demo: username <strong>admin</strong>
</p>
</div>
);
}
export default LoginPage;Test it: Try accessing /orders without login → auto-redirects to login → back to orders!
📋 Orders List (With Proper Keys!)
Real order list with add functionality + customer links. Uses keys in lists.
Create src/OrderList.js:
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from './AuthContext';
function OrderList() {
const { user } = useAuth();
const [orders, setOrders] = useState([
{ id: 1, customer: 'Acme Corp', amount: 150, status: 'pending', date: '2026-01-10' },
{ id: 2, customer: 'Beta Inc', amount: 250, status: 'shipped', date: '2026-01-09' },
{ id: 3, customer: 'Acme Corp', amount: 75, status: 'pending', date: '2026-01-08' },
{ id: 4, customer: 'Gamma Ltd', amount: 99, status: 'delivered', date: '2026-01-07' }
]);
const [newCustomer, setNewCustomer] = useState('');
const [newAmount, setNewAmount] = useState('');
const addOrder = (e) => {
e.preventDefault();
if (newCustomer && newAmount > 0) {
const newOrder = {
id: Date.now(), // Unique ID
customer: newCustomer,
amount: Number(newAmount),
status: 'pending',
date: new Date().toISOString().split('T')[0]
};
setOrders([newOrder, ...orders]);
setNewCustomer('');
setNewAmount('');
}
};
const customerId = (customer) => customer.replace(/\s+/g, '');
return (
<div>
<div className="success">
✅ {orders.length} orders loaded for {user.username}
</div>
<form onSubmit={addOrder}>
<h3>Add New Order</h3>
<input
value={newCustomer}
onChange={(e) => setNewCustomer(e.target.value)}
placeholder="Customer name"
required
/>
<input
value={newAmount}
onChange={(e) => setNewAmount(e.target.value)}
type="number"
placeholder="Order amount"
min="1"
required
/>
<button type="submit">➕ Add Order</button>
</form>
<h3>Recent Orders</h3>
<ul className="list">
{orders.map((order) => (
<li key={order.id}> {/* 👈 Stable unique key! */}
<div>
<strong>{order.customer}</strong><br />
💰 ${order.amount} • {order.date} •
<span style={{ color: order.status === 'shipped' ? 'green' : 'orange' }}>
{order.status}
</span>
</div>
<Link to={`/customers/${customerId(order.customer)}`}>
👁️ View Customer Orders
</Link>
</li>
))}
</ul>
</div>
);
}
export default OrderList;🧩 Nested Customer Orders (useEffect Magic)
Clicking a customer nests their orders under the dashboard. Uses useEffect with cleanup.
src/CustomerOrders.js:
import React, { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
function CustomerOrders() {
const { customerId } = useParams();
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const mountedRef = useRef(true);
const navigate = useNavigate();
useEffect(() => {
mountedRef.current = true;
// Simulate API call
const fetchCustomerOrders = async () => {
setLoading(true);
await new Promise(resolve => setTimeout(resolve, 1200));
const allOrders = [
{ id: 101, customer: 'AcmeCorp', amount: 150, status: 'pending', items: 2 },
{ id: 102, customer: 'AcmeCorp', amount: 75, status: 'shipped', items: 1 },
{ id: 103, customer: 'BetaInc', amount: 250, status: 'pending', items: 3 },
{ id: 104, customer: 'BetaInc', amount: 120, status: 'delivered', items: 2 },
{ id: 105, customer: 'AcmeCorp', amount: 200, status: 'pending', items: 4 }
];
const customerOrders = allOrders.filter(order =>
order.customer === customerId
);
if (mountedRef.current) {
setOrders(customerOrders);
setLoading(false);
}
};
fetchCustomerOrders();
return () => {
mountedRef.current = false; // 🧹 Cleanup prevents memory leaks
};
}, [customerId]); // 🔄 Re-run when customer changes
if (loading) {
return <div className="loading">🔄 Loading {customerId} orders...</div>;
}
return (
<div>
<button
onClick={() => navigate(-1)}
style={{ marginBottom: 20 }}
>
← Back to All Orders
</button>
<h2>📋 Orders for {customerId.replace(/([A-Z])/g, ' $1').trim()}</h2>
{orders.length === 0 ? (
<div style={{ padding: 40, textAlign: 'center', color: '#666' }}>
No orders found for this customer.
</div>
) : (
<ul className="list">
{orders.map((order) => (
<li key={order.id}>
<div>
<strong>Order #{order.id}</strong><br />
💰 ${order.amount} • 🛍️ {order.items} items •
<span style={{
color: order.status === 'delivered' ? 'green' :
order.status === 'shipped' ? 'blue' : 'orange'
}}>
{order.status}
</span>
</div>
</li>
))}
</ul>
)}
</div>
);
}
export default CustomerOrders;💡 Why end Prop? (NavLink Gotcha)
// ❌ Without 'end' - / matches /orders/new too!
<NavLink to="/">All Orders</NavLink>
// ✅ 'end' forces exact match
<NavLink to="/" end>All Orders</NavLink>🔮 What's Next?
Mastered nested routes and guards? Next: Dynamic routing and code splitting—load massive order reports only when needed!
Member discussion