React Router — Infinite Redirect Loop on Auth Guard
Synchronous localStorage read after login triggered infinite redirects; root cause: async context race.
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
- 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.
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.
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.
<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.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.
GET /products/1 with a numeric ID, don't pass productId directly from useParams() — it's the string "1", not the number 1. Use parseInt(productId, 10) or Number(productId) before passing it to any function that needs a real number. TypeScript's React Router types will catch this at compile time, which is a great reason to add TS to your project.Nested Layouts with Outlet — Shared UI Without Repetition
Almost every real app has persistent UI — a navigation bar, a sidebar, a footer — that should stay visible while the main content area changes. The naive approach is to import and render these components in every single page component. That works until you need to change the nav and have to update 20 files.
React Router's <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.
<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.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.
/→MainLayout(renders navbar, footer, and<Outlet />)/index →HomePage/products→ProductListPage/products/:productId→ProductDetailPage/dashboard→ProtectedRoute(wraps<Outlet />after auth check)/dashboardindex →DashboardHome/dashboard/settings→SettingsPage
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.
<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.<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.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.
- 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.
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).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.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.
- No more useEffect for data fetching — loaders run before render, preventing flash of loading.
- Built-in optimistic UI via
useNavigation()anduseFetcher(). - Error handling: define an
errorElementon 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.
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.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.
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.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.
- If you find yourself calling
at the top of a component (outside useEffect), refactor to use <Navigate /> or move it into an event handler.navigate() - Use
replace: truefor 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.
navigate() inside an effect without proper dependencies, leading to infinite redirects or stale navigation targets.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.
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.
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.
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.
Switch and useHistory are gone. Run the migration codemod or rip the bandage off.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.
redirect in a loader or action instead of navigate for route-level redirects — it's framework-optimized and avoids unnecessary renders.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.
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.
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.
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.
Infinite Redirect Loop on Auth Guard
replace: true which replaced the current history entry, making the loop invisible in the browser's back button.- 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.
try_files $uri /index.html;. For Netlify: add /* /index.html 200 to _redirects.<Outlet />. If using v5, check for missing exact prop. In v6, verify child routes are defined inside the parent <Route> block.className callback with isActive parameter. Check that the paths match exactly — trailing slashes matter. For partial matching, use end prop on NavLink.console.log(window.location.pathname); // Check current URLSet breakpoint in your route config to inspect whether the route definition is correct.Key takeaways
Common mistakes to avoid
4 patternsForgetting useParams() values are always strings
userId=undefined or a string where the backend expects a number), causing 400 errors or fetching the wrong data.parseInt(id, 10) or Number(id) before using them in API calls or comparisons.Missing the dependency array entry for route params in useEffect
/products/1 to /products/2 shows product 1's data until a full page refresh, because the effect never re-runs.useParams() that you use inside a useEffect in that effect's dependency array.Calling useNavigate() imperatively during the render cycle instead of using <Navigate />
<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
/dashboard), the server returns a 404 because it has no file at that path.index.html for all routes. In Nginx: try_files $uri /index.html;. In Netlify: add /* /index.html 200 to _redirects.Interview Questions on This Topic
What's the difference between and
<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.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
That's React.js. Mark it forged?
14 min read · try the examples if you haven't