Home JavaScript React Router Explained: Navigation, Dynamic Routes and Protected Pages

React Router Explained: Navigation, Dynamic Routes and Protected Pages

In Plain English 🔥
Imagine a hotel with dozens of rooms. The front desk is the main entrance — but depending on which room number you ask for, the receptionist sends you to a completely different place. React Router is that receptionist. Your app has one entrance (index.html), and React Router decides which 'room' (component) to show based on the URL the user visits. No page reload, no server trip — just instant redirection handled entirely in the browser.
⚡ Quick Answer
Imagine a hotel with dozens of rooms. The front desk is the main entrance — but depending on which room number you ask for, the receptionist sends you to a completely different place. React Router is that receptionist. Your app has one entrance (index.html), and React Router decides which 'room' (component) to show based on the URL the user visits. No page reload, no server trip — just instant redirection handled entirely in the browser.

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 · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142
// 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>
  );
}
▶ Output
// No console output — this is a declarative component tree.
// Visiting /products renders: MainLayout (with nav/footer) + ProductListPage inside it.
// Visiting /products/99 renders: MainLayout + ProductDetailPage with productId = "99".
// Visiting /unknown renders: MainLayout + NotFoundPage.
🔥
Why v6 Dropped :In v5, `` rendered the first matching ``. In v6, `` does the same thing by default — every route match is exclusive. The upgrade also means you no longer litter your route config with `exact` props, which was one of the most common sources of routing bugs in v5 apps.

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 · JAVASCRIPT
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// 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>
  );
}
▶ Output
// Visiting /products/1 renders:
// ← 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 })
⚠️
Watch Out: useParams Returns Strings, AlwaysIf your API expects `GET /products/1` with a numeric ID, don't pass `productId` directly from `useParams()` — it's the string `"1"`, not the number `1`. Use `parseInt(productId, 10)` or `Number(productId)` before passing it to any function that needs a real number. TypeScript's React Router types will catch this at compile time, which is a great reason to add TS to your project.

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 · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
// 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();
}
▶ Output
// Every route nested under "/" now renders inside this shell:
//
// ┌─────────────────────────────────────────┐
// │ NAVBAR: ShopReact [Products] 🛒 (3) │
// ├─────────────────────────────────────────┤
// │ │
// │ ← Child route component renders here │ ← This is <Outlet />
// │ (HomePage, ProductListPage, etc.) │
// │ │
// ├─────────────────────────────────────────┤
// │ FOOTER: © 2024 ShopReact │
// └─────────────────────────────────────────┘
⚠️
Pro Tip: NavLink vs LinkUse `` for any navigation where the visual state doesn't matter (e.g., a 'Back' button). Use `` for navigation menus where you want the current item styled differently — it automatically receives an `isActive` boolean in its className and style callbacks, saving you from writing a custom active-detection hook.

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 · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
// 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>
  );
}
▶ Output
// User visits /dashboard while NOT logged in:
// → 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 />
⚠️
Watch Out: Don't Use useNavigate() During RenderA common mistake is calling `const navigate = useNavigate()` and then immediately calling `navigate('/login')` at the top of a component body (outside useEffect). This fires during the render cycle and can cause React warnings about state updates during render. Always use the declarative `` component for redirect-on-render scenarios, and keep `useNavigate()` for event handlers and effects only.
Feature / AspectReact Router v5React Router v6
Route matchingFirst match via , needs `exact`Exclusive by default in , no `exact` needed
Nested routesDefined flat in one config fileComposable — defined inside parent components
Redirects component component
Programmatic nav`useHistory()` hook`useNavigate()` hook — returns a function
Route configOnly in JSXJSX or `createBrowserRouter()` object config
Shared layout UIManual — import in every pageBuilt-in via parent routes +
Relative linksMust use full pathsRelative paths work out of the box
Code splittingManual with React.lazyFirst-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=undefined or a string where the backend expects a number), causing 400 errors or fetching the wrong data — Fix: always parse URL params explicitly with parseInt(id, 10) or Number(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/1 to /products/2 shows product 1's data until a full page refresh, because the effect never re-runs — Fix: always include every value from useParams() that you use inside a useEffect in 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 save useNavigate() exclusively for event handlers and async callbacks inside useEffect.

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).

🔥
TheCodeForge Editorial Team Verified Author

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.

← PreviousReact useMemo and useCallbackNext →React Forms and Controlled Components
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged