7 min read

React Router v6 — nested routes and guards

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 start

Replace 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;

// ❌ 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!