Mid-level 4 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
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
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.
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]

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.

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.
● 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?
🔥

That's React.js. Mark it forged?

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

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