Mid-level 14 min · March 05, 2026

React Router — Infinite Redirect Loop on Auth Guard

Synchronous localStorage read after login triggered infinite redirects; root cause: async context race.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • React Router enables client-side navigation by intercepting URL changes via the History API, no page reloads.
  • Key components: BrowserRouter, Routes, Route, and Outlet for nested layouts.
  • Dynamic segments like :id are captured via useParams() — always returns strings, parse explicitly.
  • Protected routes use a wrapper component with to redirect before rendering child routes.
  • Production pitfall: Missing param in useEffect dependency array causes stale data after navigation.
✦ Definition~90s read
What is React Router?

React Router is the de facto standard client-side routing library for React applications, used by over 60% of React projects according to npm downloads. It solves a fundamental problem: mapping URL paths to component trees without full page reloads, enabling single-page applications (SPAs) to feel like multi-page sites.

Imagine a hotel with dozens of rooms.

Without it, you'd be manually managing window.location and popstate events, which quickly becomes unmanageable as your app grows. React Router gives you declarative, composable routing — you define routes as JSX or config objects, and it handles history, URL parsing, and component rendering.

It's not for server-rendered apps (use Next.js or Remix for that) or for simple static sites where a hash-based router like react-router-hash-link suffices.

Under the hood, React Router wraps the browser's History API (pushState, replaceState, popstate) and provides a context-based router that matches the current URL against a tree of route definitions. When a route matches, it renders the corresponding component and passes URL parameters via hooks like useParams.

The library uses a path-matching algorithm (based on path-to-regexp) that supports dynamic segments (:id), wildcards (*), and nested layouts via the <Outlet> component. This nesting is key: each <Route> with children renders an <Outlet> placeholder, allowing parent layouts (nav bars, sidebars) to persist while child routes swap in and out — no prop drilling or manual state management required.

React Router v6 introduced useRoutes, a hook that lets you define routes as a plain JavaScript array of objects instead of JSX. This is particularly useful for dynamic route generation, code splitting, or when routes come from an API. The config mirrors the JSX structure: { path: '/users/:id', element: <UserProfile />, children: [...] }.

Under the hood, useRoutes compiles this config into the same internal route tree as JSX, so performance and behavior are identical. The trade-off is less visual hierarchy in your code — you lose the immediate readability of nested <Route> tags, but gain flexibility for programmatic routing scenarios like role-based access control or A/B testing.

Plain-English First

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.

[Already present]

What React Router Actually Does

React Router is a client-side routing library that maps URL paths to React components, enabling single-page applications (SPAs) to render different views without a full page reload. Its core mechanic is declarative route configuration: you define a tree of <Route> elements, and the library matches the current URL against them to decide which component tree to mount. This matching is path-first, not component-first — the URL drives the UI, not the other way around.

Under the hood, React Router uses the History API (pushState, replaceState, popstate) to synchronize the browser's address bar with your component hierarchy. When a user clicks a <Link> or your code calls navigate(), the library intercepts the event, updates the URL via the History API, and then re-renders the matched route tree. It does not fetch new HTML from the server — all transitions happen in memory, which is why it's fast (sub-millisecond route matching) but also why server-side rendering requires extra care.

Use React Router when you need deep-linkable, bookmarkable URLs in a React SPA. It's the standard choice for most React applications because it integrates tightly with React's component model and provides hooks like useParams, useNavigate, and useLocation. In production, it's critical to understand that route matching is greedy by default — the first matching <Route> wins — and that nested layouts require careful outlet placement. Misconfiguring redirects or guards is the leading cause of infinite loops, especially when authentication logic lives inside route components.

Route Matching Is Not Regex
React Router uses path-to-regexp for matching, not full regex. A path like "/users/:id" matches "/users/42" but not "/users/42/edit" unless you add a wildcard or exact prop.
Production Insight
A fintech dashboard app deployed a protected route with a <Navigate> inside the auth guard component. When the token was expired, the guard redirected to /login, but /login itself had a guard that checked for a token and redirected back to /dashboard. The result: a browser tab spinning at 100% CPU, 500+ redirects per second, and a crash within 3 seconds. Rule: never place redirect logic inside a component that is rendered by the same router that handles the target route — use a layout route or a before-enter hook instead.
Key Takeaway
Route matching is path-first and greedy — order your <Route> elements from most specific to least specific.
Redirects inside route components are a direct path to infinite loops — always handle auth in a layout route or a wrapper component that does not render <Navigate>.
Use the <Outlet> pattern for nested layouts; forgetting it causes blank pages or broken hierarchies.
React Router Auth Guard Redirect Loop THECODEFORGE.IO React Router Auth Guard Redirect Loop Flow from route config to infinite redirect on protected routes Route Config Define routes with auth guard wrapper Auth Guard Check Check user authentication status Redirect to Login Navigate to /login if unauthenticated Login Page User logs in, then redirects back Infinite Loop Guard triggers redirect on every render ⚠ Guard runs on every render causing redirect loop Use useNavigate with useEffect or loader to avoid loop THECODEFORGE.IO
thecodeforge.io
React Router Auth Guard Redirect Loop
React Router

How React Router Actually Works Under the Hood

Most tutorials skip straight to <Route> 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 <BrowserRouter> 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: <Switch> was replaced with <Routes>, 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.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 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, <Switch> rendered the first matching <Route>. In v6, <Routes> 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.
Production Insight
The History API is not available in all environments — HashRouter uses the URL hash to avoid server round-trip.
In production, if you see a flash of unstyled content during route transitions, it's likely because your router is not wrapping the entire app tree.
Rule: always wrap the top-level component with <BrowserRouter> — missing it throws silent errors that look like 'useNavigate() may be used only in the context of a <Router> component.'
Key Takeaway
React Router uses the History API to change the URL without reloading the page.
Route matching in v6 is exclusive by default — first match wins, no exact prop needed.
Forgot the router? You'll get cryptic errors. Wrap your app once, at the root.

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.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// 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, Always
If 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.
Production Insight
Missing param in useEffect dependency is the #1 cause of stale data in production.
I've seen teams debug for hours — user navigates from product 1 to product 2, but product 1 data persists because the effect never re-ran.
Rule: every value from useParams() that you use inside an effect must be in its dependency array. End of story.
Key Takeaway
useParams() always returns strings — parse them before API calls or comparisons.
If your component doesn't re-fetch when the URL changes, check the useEffect dependency array.
The component stays alive; only the params change. You must react to that change explicitly.

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 <Outlet> component solves this cleanly. A parent route renders a layout component that contains <Outlet /> as a placeholder. When a child route matches, React Router injects that child's component into the <Outlet /> 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 <Outlet context={value} /> and read it in child routes via useOutletContext(). This is useful for passing things like the current authenticated user without threading props manually.

MainLayout.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// 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 Link
Use <Link> for any navigation where the visual state doesn't matter (e.g., a 'Back' button). Use <NavLink> 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.
Production Insight
A common mistake: forgetting to add <Outlet /> in the parent layout. The result? Child routes render nothing, and you spend an hour debugging.
Another trap: passing context via Outlet is great, but if you update the context value on every render, all children re-render unnecessarily. Memoize it.
Rule: <Outlet /> is not optional — if you want child routes to render, it must be present in the parent's JSX.
Key Takeaway
Use parent routes with <Outlet /> to share layouts without duplicating JSX.
Pass context via Outlet's context prop to avoid prop drilling.
Forget the Outlet? Child routes disappear. Double-check it every time you add a layout.

Nested Routing Visual Hierarchy: How Routes Map to Components

One of the trickiest concepts for devs moving from React Router v5 to v6 is understanding how nested routes compose into a component tree. In v5, nested routes were defined flat — the parent route's component had to manually render <Route> components for its children. In v6, the hierarchy is visual: your JSX tree directly mirrors the component tree that renders.

Consider this route config
  • /MainLayout (renders navbar, footer, and <Outlet />)
  • / index → HomePage
  • /productsProductListPage
  • /products/:productIdProductDetailPage
  • /dashboardProtectedRoute (wraps <Outlet /> after auth check)
  • /dashboard index → DashboardHome
  • /dashboard/settingsSettingsPage

When a user visits /dashboard/settings, the component tree is: `` <MainLayout> <ProtectedRoute> <SettingsPage /> </ProtectedRoute> </MainLayout> `` The URL changes, but the tree stays exactly the same shape. React only swaps the deepest matching component. This is why performance is excellent and transitions feel instant.

The diagram below illustrates this hierarchy — each route is a node, and nesting shows parent-child relationships.

AppRouter.jsx (nested example)JAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Visual representation of the nested route structure
// This code is not meant to be run; it shows the tree shape.
<BrowserRouter>
  <Routes>
    <Route path="/" element={<MainLayout />}>
      <Route index element={<HomePage />} />
      <Route path="products">
        <Route index element={<ProductListPage />} />
        <Route path=":productId" element={<ProductDetailPage />} />
      </Route>
      <Route element={<ProtectedRoute />}>
        <Route path="dashboard">
          <Route index element={<DashboardHome />} />
          <Route path="settings" element={<SettingsPage />} />
        </Route>
      </Route>
      <Route path="*" element={<NotFoundPage />} />
    </Route>
  </Routes>
</BrowserRouter>
Output
// Component tree for /dashboard/settings:
// <MainLayout>
// <ProtectedRoute>
// <SettingsPage />
// </ProtectedRoute>
// </MainLayout>
Visualizing the Tree
Each <Route> in v6 corresponds to a node in the component tree. The parent route's element (like MainLayout) is always rendered, and its <Outlet /> becomes the placeholder for the active child. This makes debugging easier: you can inspect the React DevTools component tree to see exactly which routes are active.
Production Insight
In production, the most common issue with nested routes is forgetting to nest <Route> elements properly. If a child route doesn't render, check that it is indeed a child of the parent <Route> block, and that the parent's element includes <Outlet />. Also ensure that ProtectedRoute (or any wrapper) renders <Outlet /> and not the child route directly — this is a frequent source of empty pages.
Key Takeaway
Nested routes in v6 form a direct parent-child component hierarchy — the JSX tree mirrors the render tree, making the flow intuitive once you see the pattern.
Nested Route Hierarchy — Each node is a route path or layout wrapper
/ (MainLayout)
index (HomePage)
/products
index (ProductListPage)
/products/:productId (ProductDetailPage)

useRoutes Hook: Declare Routes as a Config Object

While JSX-based <Routes> and <Route> components work fine, some teams prefer defining routes as a plain JavaScript object — especially when routes need to be generated dynamically (e.g., from a CMS or API). React Router's useRoutes hook accepts an array of route configuration objects and returns the matching element tree.

This approach is particularly useful for
  • Server-side route configuration (share route definitions between client and server)
  • Dynamic route generation (add/remove routes based on user permissions)
  • Reducing JSX nesting in complex apps
  • Easier testing (route config is a plain array)

The config object mirrors the structure of <Route>: path, element, children, index, loader, action, errorElement, and so on.

One caveat: useRoutes cannot be used outside of a <BrowserRouter> or <Router> context, just like any other routing hook. Also, it returns null if no route matches — you'll need a fallback component or a catch-all route.

useRoutesExample.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// routes.config.js — shared route configuration object
const routes = [
  {
    // Parent route with layout
    path: '/',
    element: <MainLayout />,
    children: [
      { index: true, element: <HomePage /> },
      { path: 'products', element: <ProductListPage /> },
      { path: 'products/:productId', element: <ProductDetailPage /> },
      {
        // Protected routes — use a wrapper element
        element: <ProtectedRoute />,
        children: [
          {
            path: 'dashboard',
            element: <DashboardLayout />,
            children: [
              { index: true, element: <DashboardHome /> },
              { path: 'settings', element: <SettingsPage /> },
            ],
          },
        ],
      },
      { path: '*', element: <NotFoundPage /> },
    ],
  },
];

export default routes;

// App.jsx — useRoutes hook
import { useRoutes } from 'react-router-dom';
import routes from './routes.config';

export default function App() {
  const element = useRoutes(routes);
  // If no route matches, element will be null
  return element ?? <NotFoundPage />; // fallback
}

// Alternative: pass a fallback route inside the config
// Add { path: '*', element: <NotFoundPage /> } at the end of children.
Output
// The output is identical to using JSX <Routes> — React Router handles the same matching logic.
// The only difference is the definition format.
// useRoutes() returns a React element or null.
When to Use useRoutes vs JSX
Use JSX for small, static apps where readability counts. Use useRoutes when routes are generated dynamically (e.g., from user permissions) or when you need to serialize route config (e.g., for SSR). The two are interchangeable — you can even mix them within the same app (not recommended for clarity).
Production Insight
One production issue with useRoutes is that it returns null when no route matches, which can cause a blank page if you don't handle it. Always provide a fallback (either in the config or as a conditional render). Also, if you update the route config dynamically (e.g., after fetching user roles), the component using useRoutes will re-render and the routes will update — but all mounted child components (like those using useParams) will unmount and remount. For smooth transitions, consider wrapping the output in <AnimatedOutlet> or using a key to preserve state.
Key Takeaway
useRoutes lets you define routes as a plain array of objects — useful for dynamic routing and easy config serialization. Always handle the null return case to prevent blank pages.

Data API: Loaders and Actions for Route-Level Data Fetching

React Router v6.4 introduced a new Data API that fundamentally changes how you handle data fetching and mutations. With createBrowserRouter, you can define loader and action functions directly on a route definition. The loader runs before the route component renders, and the action handles form submissions (POST, PUT, DELETE). This eliminates the classic useEffect + useState pattern for data loading and gives you built-in error handling, pending states, and automatic revalidation.

Loader: An async function that returns data to the route component via useLoaderData(). It runs when the route is entered or when a parent loader changes. The component can access the data synchronously (no loading state management).

Action: An async function that handles form submissions. It receives the form data and can return a response (including redirects). After the action runs, all loaders on the current page automatically revalidate — no manual refetching needed.

Key benefits
  • No more useEffect for data fetching — loaders run before render, preventing flash of loading.
  • Built-in optimistic UI via useNavigation() and useFetcher().
  • Error handling: define an errorElement on any route to catch loader/action errors.
  • Parallel loading: sibling routes load data simultaneously.

This pattern is especially powerful for data-driven apps like dashboards or e-commerce sites where route transitions should fetch fresh data without complex state management.

advancedRouter.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// router.js — using createBrowserRouter with loaders and actions
import { createBrowserRouter, RouterProvider } from 'react-router-dom';

// Mock API
const fakeProductApi = {
  getProduct: async (id) => {
    const products = {
      '1': { id: '1', name: 'Wireless Headphones', price: 89.99, stock: 14 },
      '2': { id: '2', name: 'Mechanical Keyboard', price: 129.99, stock: 0 },
    };
    await new Promise(r => setTimeout(r, 200));
    if (!products[id]) throw new Error('Product not found');
    return products[id];
  },
};

const router = createBrowserRouter([
  {
    path: '/',
    element: <MainLayout />,
    children: [
      { index: true, element: <HomePage /> },
      {
        path: 'products',
        children: [
          { index: true, element: <ProductListPage /> },
          {
            path: ':productId',
            // loader runs BEFORE the component renders
            loader: async ({ params }) => {
              const product = await fakeProductApi.getProduct(params.productId);
              return product; // available via useLoaderData()
            },
            element: <ProductDetailPage />,
            // errorElement catches loader and action errors
            errorElement: <ProductErrorBoundary />,
          },
        ],
      },
      {
        path: 'checkout',
        element: <CheckoutPage />,
        // action handles form submissions
        action: async ({ request }) => {
          const formData = await request.formData();
          const order = await fakeApi.placeOrder(formData);
          return redirect(`/orders/${order.id}`);
        },
      },
    ],
  },
]);

export default function App() {
  return <RouterProvider router={router} />;
}

// ProductDetailPage.jsx — uses useLoaderData()
import { useLoaderData, useNavigation } from 'react-router-dom';

export default function ProductDetailPage() {
  const product = useLoaderData(); // already fetched! No loading state needed.
  const navigation = useNavigation(); // optional: track pending navigation

  if (navigation.state === 'loading') {
    return <Spinner />; // during transition to another product
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <p>${product.price}</p>
    </div>
  );
}
Output
// Visiting /products/1 renders immediately with product data (no loading flash)
// Navigating to /products/2 triggers loader again, shows spinner if slow (via navigation.state)
// Submitting checkout form invokes action, then redirects to /orders/123
// If loader fails (product not found), errorElement <ProductErrorBoundary> renders instead
When to Use the Data API vs Classical React Router
If your app relies on client-side data fetching with useEffect and already works fine, you don't need to migrate. The Data API is best for new projects or sections with heavy data dependencies (dashboards, e-commerce). It pairs well with server-side rendering (Remix-style) but works entirely client-side too.
Production Insight
One gotcha: loaders run on every navigation to that route, even if the data hasn't changed. For performance-sensitive routes, implement caching inside the loader (e.g., a simple Map or a library like react-query). Also, using useNavigation().state to show a spinner during transitions can cause a flash if the network is fast — consider debouncing or using a minimum delay. Finally, never use useLoaderData() outside of a route component that has a loader defined — it will throw an error.
Key Takeaway
Loaders and actions provide route-level data fetching and mutation with automatic revalidation, error boundaries, and zero boilerplate. They replace useEffect for data loading in modern React Router apps.

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 <Navigate /> to redirect them before any protected UI ever renders. If they are authenticated, it renders <Outlet /> 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 <RequiresAdminRole /> wrapper that checks the user's role, not just whether they're logged in.

ProtectedRoute.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
// 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 Render
A 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 <Navigate /> component for redirect-on-render scenarios, and keep useNavigate() for event handlers and effects only.
Production Insight
I've seen a production incident where an auth guard used synchronous localStorage check and caused an infinite redirect loop — users were stuck between /dashboard and /login.
The fix was to introduce an async auth context that provides a loading state. The protected route shows a spinner while auth resolves, then redirects only after confirming unauthenticated.
Rule: never redirect during render based on synchronous state — use asynchronous context and <Navigate> inside a conditional render block.
Key Takeaway
Protected routes: wrap child routes with a component that conditionally renders <Navigate> or <Outlet>.
Store the original URL in location state to redirect back after login.
Avoid useNavigate() during render — use <Navigate /> instead.

Programmatic Navigation and Route Transitions

Sometimes you need to navigate in response to an event — form submission, button click, timeout — not just a link click. React Router's useNavigate() hook gives you a function you can call anywhere in your component (inside event handlers, effects, callbacks) to navigate programmatically.

useNavigate() returns a function: navigate(to, options). The to argument is a path or a number (-1 for back, 1 for forward). Options include replace (boolean) and state (any serializable data to pass to the target route). Pass state when you need to communicate transient data like 'order just placed' without polluting the URL.

One difference from <Link>: programmatic navigation doesn't automatically handle 'open in new tab'. If you need that, use a <Link> with target="_blank" or construct an anchor tag manually with window.open().

Route transitions can be enhanced with animations using libraries like framer-motion or react-transition-group. The key is wrapping the element that changes (often the <Outlet />) in an animation component that listens to route changes via useLocation().

Also worth knowing: React Router v6.4+ introduced useBlocker() and usePrompt() to prevent navigation when there are unsaved changes — critical for forms.

CartPage.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// CartPage.jsx — demonstrates programmatic navigation, state passing, and blocking navigation
import React, { useState } from 'react';
import { useNavigate, useBlocker } from 'react-router-dom';

export default function CartPage() {
  const navigate = useNavigate();
  const [cartItems, setCartItems] = useState([/* ... */]);
  const [isCheckoutPending, setIsCheckoutPending] = useState(false);

  // Block navigation if checkout is pending (prevent accidental back navigation)
  const blocker = useBlocker(
    ({ currentLocation, nextLocation }) =>
      isCheckoutPending && currentLocation.pathname !== nextLocation.pathname
  );

  function handleCheckout() {
    setIsCheckoutPending(true);
    // Simulate API call
    setTimeout(() => {
      // After success, navigate to confirmation page with order data
      navigate('/order-confirmation', {
        state: { orderId: 123, total: 49.99 },
        replace: true // Don't keep /checkout in history
      });
    }, 2000);
  }

  function goBack() {
    navigate(-1); // Go back to previous page
  }

  return (
    <div>
      <h2>Your Cart</h2>
      {/* ... */}
      <button onClick={handleCheckout} disabled={isCheckoutPending}>
        {isCheckoutPending ? 'Processing...' : 'Checkout'}
      </button>
      <button onClick={goBack}>Back</button>

      {blocker.state === 'blocked' && (
        <div>
          <p>You have an ongoing checkout. Leave anyway?</p>
          <button onClick={() => blocker.proceed()}>Leave</button>
          <button onClick={() => blocker.reset()}>Stay</button>
        </div>
      )}
    </div>
  );
}

// On the receiving end (OrderConfirmationPage.jsx):
// import { useLocation } from 'react-router-dom';
// const location = useLocation();
// const orderId = location.state?.orderId; // 123
Output
// Programmatic navigation: navigate('/order-confirmation', { state: { orderId: 123 } })
// The target page reads state: const { orderId } = useLocation().state || {};
// Back button: navigate(-1) goes to previous history entry
// useBlocker() prevents accidental navigation during pending operations
Mental Model: Programmatic Navigation as Side Effect
  • If you find yourself calling navigate() at the top of a component (outside useEffect), refactor to use <Navigate /> or move it into an event handler.
  • Use replace: true for redirects that should not create new history entries (e.g., after login, to prevent the user from going back to the login page).
  • Pass state via options.state — it's ephemeral, lost on hard refresh. Never use it for critical data.
  • For 'open in new tab', use a normal <Link> or window.open() — programmatic navigation can't open new tabs.
Production Insight
A common mistake: calling navigate() inside an effect without proper dependencies, leading to infinite redirects or stale navigation targets.
Another trap: passing large objects in state that cause performance issues because they are serialized on every navigation.
Rule: Keep state small and primitive. For complex data, use a global store and pass only an ID or key in route state.
Key Takeaway
useNavigate() returns a function for event-driven navigation — don't call it during render.
Use navigate(-1) for back buttons, navigate(path, { replace: true }) for redirects.
Route state is ephemeral; use it for transient UI data, not business-critical information.

Route Error Boundaries: Why 404s Shouldn't Crash Your App

You've seen it. A user navigates to a deleted product page, your loader throws, and React unmounts the entire tree. That's not just bad UX, it's a production incident waiting to happen. React Router 6.4+ ships with errorElement — a dedicated component that catches loader and render errors per route.

Think of it as a try/catch for your routing layer. When a loader throws or a component crashes during render, the router unwinds up the route tree until it finds the nearest errorElement. It does NOT unmount sibling routes. Your sidebar, header, or layout stays alive. Only the broken outlet gets replaced.

You don't need a global ErrorBoundary class. You don't need to wrap every page manually. Just slap an errorElement on the parent layout route and handle 404s, 500s, or auth failures in one place. The router passes the error via useRouteError(). Inspect the status, render a fallback, log it. Done.

ErrorBoundaryLayout.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// io.thecodeforge — javascript tutorial

import { Outlet, useRouteError, isRouteErrorResponse } from 'react-router-dom';

export default function RootLayout() {
  return (
    <div className="app-shell">
      <header>Static Nav</header>
      <Outlet />
    </div>
  );
}

// Matches parent route, wraps ALL children
export function LayoutErrorBoundary() {
  const error = useRouteError();

  if (isRouteErrorResponse(error) && error.status === 404) {
    return <h1>This page left the building.</h1>;
  }

  // Log to Sentry/DataDog in production
  return <h1>Something broke. We're on it.</h1>;
}
Output
Page renders normally when no error.
On 404: '<h1>This page left the building.</h1>'
Header stays visible.
Production Trap:
Don't put errorElement on leaf routes unless you want per-page error handling. Put one on the root layout. The router walks UP the tree — a layout error catches all children.
Key Takeaway
Always attach an errorElement to your root layout route. It catches loader, action, and render errors without killing the UI shell.

Resource Routes: File Downloads Without Client-Side Bloat

Your dashboard needs to export a CSV. Most devs reach for Fetch + Blob, then manually trigger a download. That works, but it's an extra round trip and you're shipping download logic to every browser. Resource routes in React Router let your route handler return raw data — not a component.

Declare a route with loader but no element. The loader returns a Response object with the right Content-Type and Content-Disposition headers. The browser handles the download natively. No component, no useEffect, no state management.

This is huge for server-rendered or Remix-style apps, but it works in client-side React Router too if you configure a custom data router. The route acts like an API endpoint inside your routing tree. You get the same auth protection, loaders, and params — but the output is a file, not JSX.

ExportCsvRoute.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge — javascript tutorial

import { generateReportCsv } from '../services/reporting';

// No element — resource route returns raw data
export async function loader({ params }) {
  const csv = await generateReportCsv(params.reportId);

  return new Response(csv, {
    status: 200,
    headers: {
      'Content-Type': 'text/csv',
      'Content-Disposition': `attachment; filename="report-${params.reportId}.csv"`
    }
  });
}
Output
User clicks link to /reports/42/export — browser downloads report-42.csv directly.
No component lifecycle, no JS bundle executed for that route.
Senior Shortcut:
Use resource routes for any non-interactive endpoint — PDF invoices, image transforms, JSON feeds. One less API endpoint to maintain.
Key Takeaway
If a route returns a file, not a UI, drop the element and return a Response from the loader. The browser does the rest.

Route Module Lazy Loading: Shrink Your Initial Bundle by 60%

Your bundle is 2MB. Users on 3G are staring at a white screen. Lazy loading isn't a React Router feature — it's React.lazy() + Suspense. But the way you wire it into routes matters. The pattern: import routes as promises, wrap them in lazy(), and set a fallback loader.

React Router 6+ supports React.lazy natively. You don't need react-loadable or custom async wrappers. Each route file becomes a separate chunk in Webpack or Vite. The first paint includes only the login page code. The dashboard — all 40 components — gets loaded only when the user navigates there.

The gotcha: lazy-loaded routes lose the ability to use loader/action from the same file unless you export them separately. The fix? Export your loader function and import it alongside the lazy component. The router will call the loader before the chunk finishes loading. Waterfall avoided.

LazyDashboardRoute.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// io.thecodeforge — javascript tutorial

import { lazy, Suspense } from 'react';
import { Route } from 'react-router-dom';

// Separate chunk — loaded on demand
const Dashboard = lazy(() => import('./pages/Dashboard'));

// Loader stays in same file, runs immediately
async function dashboardLoader() {
  const data = await fetch('/api/dashboard/meta');
  return data.json();
}

export default function AppRoutes() {
  return (
    <Route
      path="/dashboard"
      element={
        <Suspense fallback={<div>Loading dashboard...</div>}>
          <Dashboard />
        </Suspense>
      }
      loader={dashboardLoader}
    />
  );
}
Output
Initial bundle: ~200KB.
After navigating to /dashboard: lazy chunk (~400KB) fetches and loads.
Loader runs before chunk renders — no blank state.
Production Trap:
Do NOT lazy-load the root layout. It's always needed. Lazy-load only page-level routes. And never wrap the Suspense fallback inside the lazy component — it won't work.
Key Takeaway
Lazy-load every route except your login and shell layout. Pair lazy() with a Suspense fallback and export loaders separately to avoid waterfall delays.

Features: React Router 6+ Is Not the Router You Remember

React Router v6 ripped out the legacy API and replaced it with something that actually works like React. No more Switch, no more Redirect, no more manual history stack management. The core features now revolve around data-driven routing: loaders, actions, and useFetcher replace half the boilerplate you used to write in useEffect.

The router is declarative. You define routes as a tree, and the router figures out which component to render based on the URL — no imperative history.push calls unless you really want them. BrowserRouter is still the default, but createBrowserRouter with data APIs is the new standard for production apps. Features like lazy loading, error boundaries, and nested routes are baked in, not bolted on.

If you're coming from v5, unlearn everything. v6 is a different beast — leaner, faster, and built for concurrent React. Features are not optional sprinkles; they're the foundation.

FeaturesExample.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — javascript tutorial

import { createBrowserRouter, RouterProvider } from 'react-router-dom';

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: async () => {
      const data = await fetch('/api/user').then(res => res.json());
      return data;
    },
    children: [
      { index: true, element: <Home /> },
      { path: 'dashboard', element: <Dashboard />, loader: dashboardLoader },
    ],
  },
]);

export default function App() {
  return <RouterProvider router={router} />;
}
Output
Router renders Root, then loads user data before rendering Home.
Production Trap:
Don't mix v5 components with v6 routers — Switch and useHistory are gone. Run the migration codemod or rip the bandage off.
Key Takeaway
Features are not optional; v6 is a complete rewrite. Use createBrowserRouter and data loaders for any app that survives a deploy.

useHistory Is Dead — Long Live useNavigate and the Data API

You might be searching for useHistory because some ancient blog told you it's how you navigate programmatically. Wrong. That hook was removed in React Router v6. The replacement is useNavigate — a function you call to push, replace, or go back. But here's the production truth: if you're calling navigate more than once per user action, you're probably doing something wrong.

Router v6's data API (loaders, actions, and redirect) eliminates the need for manual navigation in most cases. When a form action runs, you can redirect the user to another route directly from the loader — no client-side navigation code needed. useNavigate is for edge cases: modal flows, undo gestures, or when you need to push state along with the URL.

The old useHistory pattern of reading from a global history object is gone because it was bug-prone and broke concurrent rendering. useNavigate is stable, predictable, and pairs with useLocation for reading the current URL. Stop searching for useHistory — it's dead. Bury it.

NavigateExample.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge — javascript tutorial

import { useNavigate, useLocation } from 'react-router-dom';

export default function LogoutButton() {
  const navigate = useNavigate();
  const location = useLocation();

  const handleLogout = () => {
    // Clear auth token, then redirect
    localStorage.removeItem('token');
    navigate('/login', { replace: true, state: { from: location } });
  };

  return <button onClick={handleLogout}>Log out</button>;
}
Output
Clicking button removes token and redirects to /login. State stores previous location.
Senior Shortcut:
Use redirect in a loader or action instead of navigate for route-level redirects — it's framework-optimized and avoids unnecessary renders.
Key Takeaway
useHistory is v5 baggage. Use useNavigate for programmatic navigation, but prefer data API redirects for production flows.

Route Index Files: Default Children That Eliminate Empty Parent Routes

Index routes solve a common pain point: parent routes with layouts that render nothing by default. When you nest routes like /dashboard/settings and /dashboard/profile, the parent /dashboard path shows an empty outlet unless you create a dedicated Home.js. An index route is the default child that renders when the parent path exactly matches. It lives at the parent's URL but renders inside its Outlet. This keeps your route tree clean — no extra path strings, no conditional rendering in the parent component. React Router picks the index route automatically when no other child path matches. Use index for landing pages, dashboards, or any parent layout that needs a default view, not for 404s.

App.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge — javascript tutorial

import { Route } from "react-router-dom";

export default function App() {
  return (
    <Routes>
      <Route path="/dashboard" element={<DashboardLayout />}>
        <Route index element={<DashboardHome />} />
        <Route path="settings" element={<Settings />} />
        <Route path="profile" element={<Profile />} />
      </Route>
    </Routes>
  );
}

// /dashboard renders <DashboardLayout> with <DashboardHome> inside its <Outlet>
Output
DashboardHome renders at /dashboard; /dashboard/settings renders Settings component
Production Trap:
Forgetting index on a parent route causes a blank page at the exact path — the Outlet waits for a matching child that never appears.
Key Takeaway
Every parent route with an Outlet should have an index route to avoid empty UI.

Route Path Resolution: Absolute vs. Relative Paths and the Trailing Slash Gotcha

Path resolution in nested routes is subtle but critical. Absolute paths start with / and ignore parent nesting — they mount at the root. Relative paths without / inherit their parent's path, building deeper URLs like breadcrumbs. A common mistake: /dashboard and dashboard behave differently. The trailing slash also matters — /dashboard matches exactly, while /dashboard/ matches the same URL but treats it as a directory. When you use relative paths, React Router resolves them against the parent's matched path, not its declared path. Use relative paths for most nested routes to keep your tree maintainable. Prefer absolute paths only for top-level sections like /login or /admin that don't need a parent wrapper.

Routes.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// io.thecodeforge — javascript tutorial

import { Routes, Route } from "react-router-dom";

function App() {
  return (
    <Routes>
      <Route path="/app" element={<AppLayout />}>
        {/* relative: resolves to /app/dashboard */}
        <Route path="dashboard" element={<Dashboard />} />
        {/* absolute: resolves to /settings, ignores parent */}
        <Route path="/settings" element={<Settings />} />
      </Route>
    </Routes>
  );
}

// <Dashboard> at /app/dashboard; <Settings> at /settings
Output
Navigating to /app/dashboard renders Dashboard inside AppLayout; /settings renders Settings standalone
Production Trap:
Accidentally using an absolute path (starting with /) inside a nested route breaks your layout hierarchy — the parent's Outlet is skipped entirely.
Key Takeaway
Always start nested route paths without / unless you intentionally want to escape the parent layout.

Setting Up React Router Pages: Home, Blogs, Contact

A clean route structure starts with well-defined page components. React Router doesn’t enforce any file layout, but separating concerns between routing logic and UI components keeps your app maintainable. The standard approach is to create a pages/ folder with one file per route view. Home, Blogs, and Contact each export a default component returning JSX. This isolation means you can load data, handle errors, and test pages independently. After defining the components, you import them into your router configuration. Use a top-level BrowserRouter and nest <Route> elements inside a <Routes> block. Each route maps a path (e.g., /blogs) to its component via the element prop. The result: a declarative, readable map of your app’s URLs to the screens users see. No magic — just intentional page setup that scales with your app.

Home.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
// io.thecodeforge — javascript tutorial

const Home = () => {
  return (
    <main>
      <h1>Welcome to TheCodeForge</h1>
      <p>Your source for modern React tutorials.</p>
    </main>
  );
};

export default Home;
Quick Pattern:
Keep page components pure — no NavLink or useNavigate inside them unless necessary. Let the router handle navigation.
Key Takeaway
One component per page file improves team collaboration and route debugging.

Making the Conclusion Actionable for Route Builders

React Router 6+ rewards a component-first mindset. By separating route config from page implementation, you get a codebase where adding a new page means creating one file and one route — nothing more. Lazy loading, protected routes, and error boundaries slot in without rewriting existing pages. The pattern is stable: every route gets a dedicated page component, routes are declared in a single location, and navigation stays decoupled from view logic. When your team encounters a routing bug, they can isolate it: is the route path wrong, or is a page component breaking? This separation slashes debugging time. Move forward by organizing your pages folder first, then wiring routes. The architecture you build today prevents tangled redirects and lost context tomorrow. Keep routes lean and pages focused — that’s the lasting takeaway.

Contact.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
// io.thecodeforge — javascript tutorial

const Contact = () => {
  return (
    <section>
      <h2>Contact Us</h2>
      <p>Email: team@thecodeforge.io</p>
    </section>
  );
};

export default Contact;
Production Trap:
Don’t embed route logic inside page components. A page should render UI; the router decides when to render it.
Key Takeaway
Decouple page components from routing config to future-proof your app structure.
● Production incidentPOST-MORTEMseverity: high

Infinite Redirect Loop on Auth Guard

Symptom
After successful login, user is redirected to /dashboard, but immediately sent back to /login with no error message. The browser back button loops through the same redirects. Console shows no errors, but network tab shows multiple 302 redirects.
Assumption
The team assumed that setting an auth token in localStorage and checking its existence in the protected route component would be sufficient. They also assumed <Navigate replace /> would prevent history pollution.
Root cause
The protected route was checking localStorage token on every render, but the login page also used the same token check. After login, the token was set but the protected route re-rendered before the auth context updated state asynchronously. Because the check was synchronous, the route saw no token and redirected back to login, which then saw the token and tried to redirect forward — creating an infinite loop. Additionally, the redirect used replace: true which replaced the current history entry, making the loop invisible in the browser's back button.
Fix
Replace synchronous localStorage check with an asynchronous auth context that provides a loading state. In the protected route, show a spinner while auth state resolves. Only redirect after the context confirms unauthenticated status. Use a useEffect to handle the redirect, not during render. Also add a flag to prevent redirect if the user is already on the login page.
Key lesson
  • Never trust synchronous storage reads for auth state — async context prevents race conditions.
  • Always show a loading state while auth resolves; redirect only after confirmation.
  • Use a dedicated auth provider that emits authenticated/unauthenticated as a state, not a side effect.
  • Avoid using replace in redirects during auth flows — it hides the loop from the user.
Production debug guideSymptoms and actions for the most common routing failures5 entries
Symptom · 01
Page renders blank or 404 on refresh in production
Fix
Check server configuration for URL rewriting. For Nginx: try_files $uri /index.html;. For Netlify: add /* /index.html 200 to _redirects.
Symptom · 02
Component does not re-fetch data when URL param changes
Fix
Verify that the useEffect dependency array includes the param from useParams(). Without it, the effect runs only once and shows stale data.
Symptom · 03
User redirected to login repeatedly after authenticating
Fix
Check if protected route component uses synchronous storage check. Refactor to async auth context with a loading state before rendering the route.
Symptom · 04
Nested route does not render inside parent layout
Fix
Ensure parent route's element includes <Outlet />. If using v5, check for missing exact prop. In v6, verify child routes are defined inside the parent <Route> block.
Symptom · 05
NavLink active class not applied correctly
Fix
Use the className callback with isActive parameter. Check that the paths match exactly — trailing slashes matter. For partial matching, use end prop on NavLink.
★ React Router Quick Debug Cheat SheetFast commands and checks for the most common routing problems in production
Blank page on route
Immediate action
Open browser DevTools Console tab and look for React Router errors or missing context.
Commands
console.log(window.location.pathname); // Check current URL
Set breakpoint in your route config to inspect whether the route definition is correct.
Fix now
Wrap your app in <BrowserRouter> and ensure <Routes> is present. Add a catch-all route with path="*" to debug unmatched paths.
Data not updating when navigating to same route with different param+
Immediate action
Check the useEffect dependency array in the component that fetches data.
Commands
console.log(useParams()); // Shows current param values
console.log('Effect ran'); inside the useEffect to see if it triggers on navigation.
Fix now
Add the param(s) from useParams() into the dependency array of the data-fetching useEffect.
Protected route shows protected content before redirecting+
Immediate action
Check that the auth check is synchronous and happens before rendering Outlet.
Commands
Check React DevTools Components tab — the protected route component should not mount child routes if user is not authenticated.
Add a console.log in the protected route's render to see order of execution.
Fix now
Use a wrapper component that returns <Navigate> if not authenticated, <Outlet> otherwise. Do not use useNavigate() during render.
React Router v5 vs v6 Feature Comparison
Feature / AspectReact Router v5React Router v6
Route matchingFirst match via <Switch>, needs exactExclusive by default in <Routes>, no exact needed
Nested routesDefined flat in one config fileComposable — defined inside parent components
Redirects<Redirect> component<Navigate> component
Programmatic navuseHistory() hookuseNavigate() hook — returns a function
Route configOnly in JSXJSX or createBrowserRouter() object config
Shared layout UIManual — import in every pageBuilt-in via parent routes + <Outlet />
Relative linksMust use full pathsRelative paths work out of the box
Code splittingManual with React.lazyFirst-class support via lazy() in route config
Data loadingNo built-in supportloader and action functions in route config
Error handlingManual with error boundarieserrorElement in route config

Key takeaways

1
React Router intercepts browser navigation using the History API
no server round-trips, no page reloads, your React tree stays alive the whole time.
2
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.
3
The <Outlet /> + 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.
4
Use <Navigate /> (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.
5
Programmatic navigation via useNavigate() should never be called during render
it's a side effect meant for event handlers and async callbacks.

Common mistakes to avoid

4 patterns
×

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

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

Calling useNavigate() imperatively during the render cycle instead of using <Navigate />

Symptom
React throws a warning like 'Cannot update a component while rendering a different component', and navigation behavior becomes unpredictable.
Fix
Use the declarative <Navigate to="/login" replace /> 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.
×

Not handling the 404 on page refresh in production deployments

Symptom
When the user refreshes the browser on a client-side route (e.g., /dashboard), the server returns a 404 because it has no file at that path.
Fix
Configure your web server to serve index.html for all routes. In Nginx: try_files $uri /index.html;. In Netlify: add /* /index.html 200 to _redirects.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What's the difference between and in React Router, and ...
Q02SENIOR
How would you implement a route that redirects unauthenticated users to ...
Q03SENIOR
If a user navigates from /products/1 to /products/2 and the new product ...
Q04SENIOR
Explain how React Router handles nested layouts with and how yo...
Q01 of 04JUNIOR

What's the difference between and in React Router, and when would you choose one over the other in a production app?

ANSWER
<Link> is the basic component for navigation. It renders an anchor tag and navigates to the given to path without a page reload. You use it for any link where you don't need to style the current page differently — back buttons, sidebar links that always look the same, etc. <NavLink> extends <Link> and automatically adds an isActive boolean to its className and style callbacks. This makes it perfect for navigation menus where the current page should be visually highlighted. For example, in a sidebar with links to /dashboard, /orders, /settings, NavLink lets you easily apply an 'active' class to the current section. In production, use <Link> for generic navigation (breadcrumbs, 'go back', external links) and <NavLink> for primary navigation menus that need active state styling. Overusing NavLink when you don't need active detection adds unnecessary re-renders — each NavLink subscribes to location changes.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between BrowserRouter and HashRouter in React Router?
02
How do I pass data between routes in React Router without putting it in the URL?
03
Why does my app show a blank page or 404 when I refresh on a React Router route in production?
04
Can I use React Router with TypeScript? How do I type route params?
05
How do I handle scroll restoration when navigating between pages?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's React.js. Mark it forged?

14 min read · try the examples if you haven't

Previous
React useMemo and useCallback
7 / 47 · React.js
Next
React Forms and Controlled Components