React Router Explained: Navigation, Dynamic Routes and Protected Pages
Single-page applications are everywhere — Gmail, Notion, GitHub — and they all share one invisible superpower: navigation that feels instant. When you click a link and the page updates in milliseconds without a full browser reload, that's client-side routing at work. For React apps, React Router is the de-facto library that makes this possible, and understanding it deeply separates developers who build toy apps from those who build production systems.
Before React Router existed, every navigation action triggered a full round-trip to the server — fetch HTML, paint the page, lose all state. That's fine for a blog, but catastrophic for a dashboard where a user has unsaved form data. React Router solves this by intercepting navigation events, mapping URL patterns to components, and rendering only what needs to change — keeping your React tree alive the whole time.
By the end of this article you'll be able to build a multi-page React app with nested layouts, dynamic URL segments like /user/42/profile, programmatic navigation, and protected routes that redirect unauthenticated users — the exact patterns you'll need in every professional project.
How React Router Actually Works Under the Hood
Most tutorials skip straight to syntax without explaining the machinery. Let's fix that.
React Router is built on the History API — a browser-native interface that lets JavaScript manipulate the URL bar without triggering a page load. When you call navigate('/dashboard'), React Router pushes a new entry onto the browser's history stack, reads the new URL, matches it against your route definitions, and re-renders the matching components. The server never sees this navigation.
This is why wrapping your app in is non-negotiable. It creates a context that holds the current location and exposes it to every nested component. Without it, hooks like useNavigate and useParams have no context to read from and will throw.
React Router v6 (the current version) made a significant architectural shift: was replaced with , and route matching became exclusive by default — meaning the first match wins and only that component renders. You no longer need the exact prop. Routes also became composable, so nested routes live inside parent components rather than all being declared in one giant config file.
// AppRouter.jsx — the root routing setup for a small e-commerce app import React from 'react'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; import MainLayout from './layouts/MainLayout'; import HomePage from './pages/HomePage'; import ProductListPage from './pages/ProductListPage'; import ProductDetailPage from './pages/ProductDetailPage'; import NotFoundPage from './pages/NotFoundPage'; export default function AppRouter() { return ( // BrowserRouter creates the History API context — everything inside // can read the current URL and react to navigation events <BrowserRouter> <Routes> {/* The path="/" route wraps shared UI (nav, footer) via MainLayout. Child routes render inside MainLayout's <Outlet /> placeholder. */} <Route path="/" element={<MainLayout />}> {/* index means: render this when path is exactly "/" */} <Route index element={<HomePage />} /> {/* Static nested route — maps to /products */} <Route path="products" element={<ProductListPage />} /> {/* Dynamic segment: the colon prefix (:productId) means "capture whatever is here as a named parameter". /products/42 → productId = "42" /products/apple-watch → productId = "apple-watch" */} <Route path="products/:productId" element={<ProductDetailPage />} /> {/* Catch-all: path="*" matches anything that didn't match above */} <Route path="*" element={<NotFoundPage />} /> </Route> </Routes> </BrowserRouter> ); }
// Visiting /products renders: MainLayout (with nav/footer) + ProductListPage inside it.
// Visiting /products/99 renders: MainLayout + ProductDetailPage with productId = "99".
// Visiting /unknown renders: MainLayout + NotFoundPage.
Dynamic Routes, URL Params and the useParams Hook
Static routes handle predictable paths like /about or /contact. But real apps deal with data-driven URLs — product pages, user profiles, order receipts — where the path contains an ID that determines what gets fetched. That's where dynamic segments come in.
When you define path="products/:productId", React Router captures the :productId segment and makes it available via the useParams() hook inside the rendered component. The returned value is always a string, so if your API expects a number, you must parse it explicitly — a silent bug that bites constantly.
You can have multiple dynamic segments in one path: path="orders/:orderId/items/:itemId" is perfectly valid. Each segment becomes a key in the object useParams() returns.
One pattern worth knowing: when a URL param changes (e.g., navigating from /products/1 to /products/2), React doesn't unmount and remount the component — it just re-renders it with new params. This means any useEffect that fetches data based on a param must list that param as a dependency, otherwise you'll display stale data from the previous route.
// ProductDetailPage.jsx — reads a URL param and fetches matching product data import React, { useEffect, useState } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; // Simulated async API call — replace with your real fetch/axios call async function fetchProductById(id) { const mockProducts = { '1': { id: 1, name: 'Wireless Headphones', price: 89.99, stock: 14 }, '2': { id: 2, name: 'Mechanical Keyboard', price: 129.99, stock: 0 }, }; // Simulate network latency await new Promise(resolve => setTimeout(resolve, 400)); return mockProducts[id] ?? null; // Return null if product doesn't exist } export default function ProductDetailPage() { // useParams returns an object matching your route's dynamic segments // { productId: "1" } — always a string, even if the URL contains "1" const { productId } = useParams(); const navigate = useNavigate(); // For programmatic navigation const [product, setProduct] = useState(null); const [isLoading, setIsLoading] = useState(true); useEffect(() => { setIsLoading(true); fetchProductById(productId) .then(data => { if (!data) { // Product doesn't exist — redirect to the 404 page navigate('/not-found', { replace: true }); return; } setProduct(data); }) .finally(() => setIsLoading(false)); // CRITICAL: productId must be in the dependency array. // Without it, navigating from /products/1 to /products/2 // would show product 1's data on the /products/2 URL. }, [productId, navigate]); if (isLoading) return <p>Loading product...</p>; if (!product) return null; // navigate() is already firing return ( <div className="product-detail"> <Link to="/products">← Back to all products</Link> <h1>{product.name}</h1> <p>Price: ${product.price.toFixed(2)}</p> <p>Status: {product.stock > 0 ? `${product.stock} in stock` : 'Out of stock'}</p> <button disabled={product.stock === 0} onClick={() => navigate('/cart', { state: { productId: product.id } })} > Add to Cart </button> </div> ); }
// ← Back to all products
// Wireless Headphones
// Price: $89.99
// Status: 14 in stock
// [Add to Cart] button (enabled)
// Visiting /products/2 renders:
// ← Back to all products
// Mechanical Keyboard
// Price: $129.99
// Status: Out of stock
// [Add to Cart] button (disabled)
// Visiting /products/999 triggers navigate('/not-found', { replace: true })
Nested Layouts with Outlet — Shared UI Without Repetition
Almost every real app has persistent UI — a navigation bar, a sidebar, a footer — that should stay visible while the main content area changes. The naive approach is to import and render these components in every single page component. That works until you need to change the nav and have to update 20 files.
React Router's component solves this cleanly. A parent route renders a layout component that contains as a placeholder. When a child route matches, React Router injects that child's component into the position. The parent layout re-renders zero times — only the outlet's content swaps.
This pattern scales beautifully. You can nest layouts multiple levels deep — an app layout containing an authenticated layout containing a settings layout — each adding its own framing UI without knowing anything about its children.
One underused feature: you can pass context data down through and read it in child routes via useOutletContext(). This is useful for passing things like the current authenticated user without threading props manually.
// MainLayout.jsx — persistent shell that wraps all public-facing pages import React from 'react'; import { Outlet, NavLink, useNavigate } from 'react-router-dom'; // useOutletContext lets child routes read values passed via <Outlet context={}> import { useOutletContext } from 'react-router-dom'; const SITE_NAME = 'ShopReact'; export default function MainLayout() { const navigate = useNavigate(); // Simulated cart count — in real life this comes from global state (Zustand/Redux) const cartItemCount = 3; return ( <div className="app-shell"> {/* ── NAVBAR — renders on every route wrapped by this layout ── */} <header className="navbar"> <NavLink to="/" className="navbar__logo"> {SITE_NAME} </NavLink> <nav className="navbar__links"> {/* NavLink is like Link, but automatically adds an "active" CSS class when its href matches the current URL. Perfect for highlighting the current section in your nav menu. */} <NavLink to="/products" className={({ isActive }) => isActive ? 'nav-link nav-link--active' : 'nav-link' } > Products </NavLink> </nav> <button className="navbar__cart-button" onClick={() => navigate('/cart')} > 🛒 Cart ({cartItemCount}) </button> </header> {/* ── MAIN CONTENT AREA ── */} <main className="page-content"> {/* <Outlet /> is the magic placeholder. React Router replaces this with whichever child route currently matches. The navbar and footer above/below never unmount — zero flicker. We pass cartItemCount as context so any child page can read it via useOutletContext() without prop drilling. */} <Outlet context={{ cartItemCount }} /> </main> {/* ── FOOTER — same deal, always visible ── */} <footer className="footer"> <p>© {new Date().getFullYear()} {SITE_NAME}. All rights reserved.</p> </footer> </div> ); } // ── Helper hook — export this so child pages can import it with types ── // Usage in any child page: const { cartItemCount } = useCartContext(); export function useCartContext() { return useOutletContext(); }
//
// ┌─────────────────────────────────────────┐
// │ NAVBAR: ShopReact [Products] 🛒 (3) │
// ├─────────────────────────────────────────┤
// │ │
// │ ← Child route component renders here │ ← This is <Outlet />
// │ (HomePage, ProductListPage, etc.) │
// │ │
// ├─────────────────────────────────────────┤
// │ FOOTER: © 2024 ShopReact │
// └─────────────────────────────────────────┘
Protected Routes — Redirect Unauthenticated Users the Right Way
Every app with user accounts has pages that should only be accessible when logged in. A naive approach might be checking auth state inside each page component and calling navigate('/login') if the user isn't authenticated. That works, but it means scattering auth logic across every protected page — a maintenance nightmare.
The idiomatic React Router pattern is a ProtectedRoute wrapper component. It reads auth state in one place, and if the user isn't authenticated, it immediately renders to redirect them before any protected UI ever renders. If they are authenticated, it renders to let the matched child route through.
The key detail is storing the originally requested URL in the redirect. When a logged-out user hits /dashboard, you want to send them to /login?redirect=/dashboard so after they authenticate, you can send them exactly where they wanted to go — not just to a generic home page. This is the difference between a frustrating UX and a seamless one.
This pattern also composes naturally with role-based access: you can create a wrapper that checks the user's role, not just whether they're logged in.
// ProtectedRoute.jsx — guards any routes nested inside it import React from 'react'; import { Navigate, Outlet, useLocation } from 'react-router-dom'; // In a real app this hook reads from your auth context / Zustand store / Redux function useAuthStatus() { // Simulating: user is NOT logged in // Swap to `return { isAuthenticated: true, user: { role: 'admin' } }` to test the happy path return { isAuthenticated: false, user: null }; } /** * ProtectedRoute wraps any routes that require authentication. * Use it as a parent route in your router config — child routes * render via <Outlet /> only when the user is authenticated. * * @param {string} requiredRole - Optional. If provided, also checks user.role. */ export default function ProtectedRoute({ requiredRole = null }) { const { isAuthenticated, user } = useAuthStatus(); // useLocation gives us the current URL so we can redirect back after login const currentLocation = useLocation(); // ── STEP 1: Is the user logged in at all? ── if (!isAuthenticated) { /* <Navigate> is the declarative way to redirect during render. Don't use useNavigate() inside the render path — it can cause issues with React's concurrent rendering. `state` carries the originally-requested path so the login page can redirect back after a successful login. `replace` means the login redirect won't create a new history entry — pressing Back won't loop the user through the login page again. */ return ( <Navigate to="/login" state={{ redirectAfterLogin: currentLocation.pathname }} replace /> ); } // ── STEP 2: Does this route require a specific role? ── if (requiredRole && user?.role !== requiredRole) { // User is logged in but lacks the required permission return <Navigate to="/unauthorized" replace />; } // ── STEP 3: All checks passed — render the child routes ── return <Outlet />; } // ───────────────────────────────────────────────────────────────────────────── // HOW TO USE THIS IN YOUR ROUTER CONFIG: // // <Routes> // <Route path="/" element={<MainLayout />}> // <Route index element={<HomePage />} /> // <Route path="products" element={<ProductListPage />} /> // // {/* All routes inside here require login */} // <Route element={<ProtectedRoute />}> // <Route path="dashboard" element={<DashboardPage />} /> // <Route path="orders" element={<OrdersPage />} /> // </Route> // // {/* This route also requires the "admin" role */} // <Route element={<ProtectedRoute requiredRole="admin" />}> // <Route path="admin" element={<AdminPanel />} /> // </Route> // </Route> // </Routes> // ───────────────────────────────────────────────────────────────────────────── // LoginPage.jsx — reads the redirect state and uses it after login export function LoginPage() { const navigate = useNavigate(); const location = useLocation(); // Pull out where the user was trying to go before being redirected const redirectTarget = location.state?.redirectAfterLogin ?? '/dashboard'; function handleSuccessfulLogin() { // ... your real auth logic here ... // Once auth succeeds, send them where they originally wanted to go navigate(redirectTarget, { replace: true }); } return ( <div> <h1>Log In</h1> <button onClick={handleSuccessfulLogin}>Simulate Login</button> </div> ); }
// → Immediately redirected to /login
// → location.state = { redirectAfterLogin: '/dashboard' }
// → No flash of protected content
// User visits /admin with role "editor" (not "admin"):
// → Immediately redirected to /unauthorized
// User visits /dashboard while logged in:
// → DashboardPage renders normally via <Outlet />
| Feature / Aspect | React Router v5 | React Router v6 |
|---|---|---|
| Route matching | First match via | Exclusive by default in |
| Nested routes | Defined flat in one config file | Composable — defined inside parent components |
| Redirects | ||
| Programmatic nav | `useHistory()` hook | `useNavigate()` hook — returns a function |
| Route config | Only in JSX | JSX or `createBrowserRouter()` object config |
| Shared layout UI | Manual — import in every page | Built-in via parent routes + |
| Relative links | Must use full paths | Relative paths work out of the box |
| Code splitting | Manual with React.lazy | First-class support via `lazy()` in route config |
🎯 Key Takeaways
- React Router intercepts browser navigation using the History API — no server round-trips, no page reloads, your React tree stays alive the whole time.
- useParams() always returns strings — parse them explicitly before passing to APIs or numeric comparisons, or you'll chase subtle bugs that only appear in production.
- The
+ parent route pattern is how you build shared layouts (navbars, sidebars, footers) without duplicating JSX across every page — this is the pattern React Router was designed around. - Use
(declarative) for auth guard redirects during render, and useNavigate() (imperative) only inside event handlers and useEffect callbacks — mixing them up is the most common intermediate-level React Router mistake.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Forgetting useParams() values are always strings — Symptom: API calls send the wrong type (e.g.,
userId=undefinedor a string where the backend expects a number), causing 400 errors or fetching the wrong data — Fix: always parse URL params explicitly withparseInt(id, 10)orNumber(id)before using them in API calls or comparisons. - ✕Mistake 2: Missing the dependency array entry for route params in useEffect — Symptom: navigating from
/products/1to/products/2shows product 1's data until a full page refresh, because the effect never re-runs — Fix: always include every value fromuseParams()that you use inside auseEffectin that effect's dependency array. - ✕Mistake 3: Calling useNavigate() imperatively during the render cycle instead of using
— Symptom: React throws a warning like 'Cannot update a component while rendering a different component', and navigation behavior becomes unpredictable — Fix: use the declarative component for any redirect that should happen as part of rendering (like auth guards), and saveuseNavigate()exclusively for event handlers and async callbacks insideuseEffect.
Interview Questions on This Topic
- QWhat's the difference between and
in React Router, and when would you choose one over the other in a production app? - QHow would you implement a route that redirects unauthenticated users to a login page and then sends them back to their originally requested URL after they log in?
- QIf a user navigates from /products/1 to /products/2 and the new product doesn't load, what are the two most likely causes and how would you debug them?
Frequently Asked Questions
What is the difference between BrowserRouter and HashRouter in React Router?
BrowserRouter uses the HTML5 History API and produces clean URLs like /products/1. HashRouter puts a # in the URL (/#/products/1) and stores the route in the URL hash, which never gets sent to the server. Use BrowserRouter for any app deployed to a proper server or CDN that supports URL rewriting. Use HashRouter only for static file hosting (like GitHub Pages) where you can't configure server-side URL rewrites, because the hash portion is guaranteed to stay client-side.
How do I pass data between routes in React Router without putting it in the URL?
Use the state option on navigate() or the state prop on . For example: navigate('/order-confirmation', { state: { orderId: 99 } }). The receiving page reads it with const location = useLocation(); const { orderId } = location.state;. Be aware that location state is ephemeral — it disappears on a hard refresh, so never rely on it as a primary data source. It's best for transient UI state like 'show a success toast' or 'pre-fill a form field'.
Why does my app show a blank page or 404 when I refresh on a React Router route in production?
This is the most common React Router deployment gotcha. When you refresh /products/42, the browser sends a real HTTP request for /products/42 to your server. Your server has no file at that path — only index.html exists — so it returns a 404. The fix is to configure your server to serve index.html for all routes and let React Router handle the rest client-side. In Nginx this is try_files $uri /index.html;, in Apache it's a .htaccess rewrite rule, and in Vite/Create React App deployments you set this in your hosting config (Netlify uses a _redirects file with /* /index.html 200).
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.