React Router for SPAs: Nested Routes, Loaders and Actions, Error Boundaries and Protected Routes

Key takeaways

React Router is the standard routing library for React applications. It enables navigation between views, URL parameter handling, and data loading for single-page applications.

Introduction

React Router is the standard routing library for React. It enables client-side routing, allowing users to navigate between different views without full page reloads.

React itself has no concept of a “page.” A React application is, by default, a single component tree that renders once and then updates in place as state changes. That model works fine for a small widget, but the moment you build something with more than one logical view — a dashboard, a settings screen, a list-and-detail flow — you run into a problem the browser has solved for over two decades: the address bar. Users expect /users/42 to mean something specific, expect the back button to undo the last navigation, and expect a bookmarked link to reopen the exact screen they saved. None of that comes for free in a single-page app; it has to be built on top of the browser’s History API (pushState, popstate), and getting the edge cases right — nested UI, scroll restoration, data that needs to be ready before a view renders — is significantly more work than it looks. React Router exists to own that layer so individual applications don’t have to reinvent it, and its evolution (from a simple component-based router in v3/v4 to the data-aware router introduced in v6.4, and later folded into React Router v7 alongside Remix) tracks exactly that growing scope: routing stopped being “which component do I show” and became “which component, with what data, and how do errors and pending states flow through that.”

Without React Router

function App() {
  const [page, setPage] = useState('home');
  
  return (
    <div>
      <button onClick={() => setPage('home')}>Home</button>
      <button onClick={() => setPage('about')}>About</button>
      
      {page === 'home' && <Home />}
      {page === 'about' && <About />}
    </div>
  );
}

Problems:

  • ❌ No URL updates
  • ❌ No browser back/forward
  • ❌ No bookmarking
  • ❌ No deep linking

This useState-driven approach is a common first instinct, and it is not wrong for a two-tab settings modal — but it silently breaks a set of user expectations the moment the app grows past a toy example. The browser’s back button does nothing, because nothing ever wrote an entry into session history; the address bar always shows the same URL regardless of which “page” is active, so there is nothing to bookmark, share, or paste into a support ticket; and a hard refresh always drops the user back to whatever page was initialized to, discarding their place in the app. These aren’t cosmetic issues — they are the exact behaviors search engines, analytics tools, and users all rely on to treat an application as a set of addressable resources rather than one opaque blob. Fixing them by hand means listening to popstate, calling history.pushState on every transition, and re-deriving state from location.pathname on load — which is precisely the boilerplate React Router abstracts away behind <Link>, <Routes>, and <Route>.

With React Router

import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';

function App() {
  return (
    <BrowserRouter>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/about">About</Link>
      </nav>
      
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </BrowserRouter>
  );
}

Installation

npm install react-router-dom

A note on versions that trips up a lot of teams searching for tutorials: react-router-dom was the correct package name through React Router 6, but React Router 7 (released in late 2024, after the project’s data APIs and Remix were merged into a single codebase) consolidated the web bindings back under the plain react-router package, with react-router-dom kept only as a compatibility re-export. If you’re starting a new project today, npm install react-router and the v7 APIs are the current recommendation; everything in this guide targets the v6 API surface (react-router-dom), which remains widely deployed and is what you’ll find in the majority of production codebases and existing tutorials as of this writing. The core concepts — route matching, nested layouts, loaders, actions — carry over almost unchanged between v6 and v7, so this guide is still the right place to build a mental model even if your import path eventually changes.

Basic Routing

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

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/contact" element={<Contact />} />
        <Route path="*" element={<NotFound />} />
      </Routes>
    </BrowserRouter>
  );
}

<Routes> and <Route> look declarative, but there is real matching logic underneath worth understanding, because it explains bugs you’ll hit later. React Router ranks every route against the current URL using a scoring system similar to how a router like Express or a URL pattern matcher would — static segments score higher than dynamic ones (/users/settings beats /users/:id when the path is literally /users/settings), and it then picks the single best match rather than the first one that matches textually. That’s why route order in this basic form matters far less than developers coming from older routing libraries expect: you generally don’t need to carefully sort routes from most-specific to least-specific, because the ranking algorithm does that for you. The one route that’s always evaluated last, by design, is the catch-all path="*" — it exists specifically as a fallback for anything that doesn’t match a more specific pattern, which is why it’s the idiomatic way to implement a 404 page. If you’ve used Vue Router or Angular’s router before, the mental adjustment is that path order in code isn’t the source of truth here; specificity is.

import { Link } from 'react-router-dom';

function Nav() {
  return (
    <nav>
      <Link to="/">Home</Link>
      <Link to="/about">About</Link>
      <Link to="/users/123">User 123</Link>
    </nav>
  );
}

It’s worth being explicit about why <Link> exists instead of just writing an <a href="/about"> tag. A plain anchor tag works, but clicking it triggers a full browser navigation: the entire page tears down, every script re-parses, and any in-memory state (form inputs the user hasn’t submitted yet, a scroll position, a WebSocket connection) is lost. <Link> renders an <a> element under the hood — so it still gets right-click “open in new tab,” Ctrl/Cmd+click, and screen-reader link semantics for free — but it intercepts the plain left-click and hands the navigation to React Router’s internal history management instead of letting the browser reload. The URL still updates, a history entry still gets pushed, but the JavaScript runtime and component tree stay alive across the transition. This is the entire reason a “single-page application” is fast after the first load: every subsequent navigation is just a re-render, not a fresh network round-trip for the HTML document plus every script and stylesheet.

import { NavLink } from 'react-router-dom';

function Nav() {
  return (
    <nav>
      <NavLink
        to="/"
        className={({ isActive }) => isActive ? 'active' : ''}
      >
        Home
      </NavLink>
      
      <NavLink
        to="/about"
        style={({ isActive }) => ({
          color: isActive ? 'red' : 'black'
        })}
      >
        About
      </NavLink>
    </nav>
  );
}

NavLink is Link plus one thing: it knows, on every render, whether its own to target matches the current URL, and exposes that as the isActive boolean passed into the className/style callback props. Without it, “highlight the current nav item” requires manually comparing useLocation().pathname against every link’s target and wiring up the conditional class yourself — trivial for one link, tedious and easy to get subtly wrong (trailing slashes, partial-path matches) across a whole navigation bar. NavLink also exposes isPending (when a linked route has a loader that’s still fetching, useful for showing a spinner mid-transition) and supports an end prop that matters more than it looks: by default, NavLink treats a link as active if the current URL merely starts with its target, so a link to / would render as active on every route unless you pass end to require an exact match. That’s the single most common NavLink bug reports trace back to — a root-level nav item staying highlighted everywhere.

Programmatic Navigation

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

function LoginForm() {
  const navigate = useNavigate();
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    await login();
    navigate('/dashboard'); // Navigate after login
  };
  
  return <form onSubmit={handleSubmit}>...</form>;
}

useNavigate is the escape hatch for the cases where a navigation should happen as a side effect of code running, rather than as a direct response to a user clicking a link — after a successful login, after a mutation completes, after a timed session expires, or when redirecting away from a route the user isn’t allowed to see. The temptation is to reach for it everywhere, but it’s worth resisting for anything a user would naturally click: a <button onClick={() => navigate('/about')}> loses the semantic and accessibility benefits <Link> gives you for free (keyboard focus handling, middle-click-to-open-in-new-tab, search-engine crawlability of the link) and forces you to reimplement them badly if you need them later. A second subtlety: navigate('/dashboard') pushes a new history entry by default, so pressing the browser’s back button after a redirect lands the user back on the page that triggered it — usually not what you want after, say, a login redirect, where you’d rather back-navigate skip past the login screen entirely. Passing { replace: true } swaps the current history entry instead of adding a new one, which is the right call for exactly that kind of “this page shouldn’t exist in back-button history” redirect.

URL Parameters

Dynamic Routes

<Routes>
  <Route path="/users/:id" element={<UserProfile />} />
  <Route path="/posts/:year/:month/:slug" element={<Post />} />
</Routes>

The :id syntax marks a path segment as dynamic — React Router captures whatever the browser actually requested at that position and hands it to the component rather than requiring a fixed value. This is what lets one <Route> and one UserProfile component serve every user in your system instead of needing a route defined per user. Multiple dynamic segments compose naturally, as the blog-post example shows (/posts/2026/04/react-router-guide), and React Router also supports an optional-segment syntax (:id?) and a catch-all splat (*, or /files/* to capture an arbitrary-depth path suffix) for the less common cases — a search page that works with or without a category in the URL, or a file browser mirroring a nested folder structure. One gotcha worth flagging early: route params are always strings, even when the value is conceptually numeric (a year, a page number, a database ID) — you’ll need to Number() or validate them explicitly before using them as anything other than a string, and that validation is exactly the kind of thing a loader (covered later in this guide) is a natural place to do.

Reading Parameters

import { useParams } from 'react-router-dom';

function UserProfile() {
  const { id } = useParams();
  
  return <div>User ID: {id}</div>;
}

useParams reads from React Router’s internal matching context rather than parsing window.location directly, which matters because it means the hook only re-renders the component when the matched params themselves change, not on every unrelated navigation elsewhere in the app. It also means useParams only works inside a component that’s actually rendered as part of a matched <Route> — calling it from a component rendered outside the router tree (or one that isn’t itself route-mapped) returns an empty object rather than throwing, which is a common source of “why is id undefined” bugs when a component gets reused both inside and outside a routed context. In TypeScript, useParams returns a loosely-typed Readonly<Params<string>> by default, so destructuring id gives you string | undefined, not string — the compiler is technically correct that a route could theoretically be reached without the param present (a route defined without :id reusing the same component, for instance), so production code generally needs an explicit if (!id) return <NotFound /> guard rather than assuming presence.

Query Strings

import { useSearchParams } from 'react-router-dom';

function SearchPage() {
  const [searchParams, setSearchParams] = useSearchParams();
  
  const query = searchParams.get('q');
  const page = searchParams.get('page') || '1';
  
  const handleSearch = (newQuery) => {
    setSearchParams({ q: newQuery, page: '1' });
  };
  
  return (
    <div>
      <p>Searching for: {query}</p>
      <p>Page: {page}</p>
    </div>
  );
}

Route params (:id) and query strings (?q=...&page=...) solve different problems even though both live in the URL, and mixing them up is a common design mistake. A route param should identify which resource you’re looking at — it’s part of what makes the URL a stable, unique address for a specific thing, the way /users/123 is fundamentally a different page from /users/456. A query string should describe how you’re viewing or filtering a resource — search terms, sort order, pagination offset, an open modal — state that’s still worth reflecting in the URL (so it survives a refresh and is shareable) but doesn’t change what the page conceptually is. useSearchParams mirrors the useState/setState pattern deliberately, but it comes with two practical gotchas. First, searchParams.get('q') always returns string | null, never a parsed number or boolean, so the same manual-coercion discipline from route params applies here too. Second, setSearchParams replaces the entire query string by default rather than merging into it — calling setSearchParams({ q: newQuery }) silently drops any other param (like page) that isn’t included in the object you pass, which is exactly why the example above explicitly re-includes page: '1' on every search rather than just updating q alone; forgetting this is one of the most common bugs teams hit when a filter bar has more than one independent control.

Nested Routes

<Routes>
  <Route path="/" element={<Layout />}>
    <Route index element={<Home />} />
    <Route path="about" element={<About />} />
    <Route path="users" element={<Users />}>
      <Route index element={<UsersList />} />
      <Route path=":id" element={<UserProfile />} />
      <Route path=":id/settings" element={<UserSettings />} />
    </Route>
  </Route>
</Routes>
import { Outlet } from 'react-router-dom';

function Layout() {
  return (
    <div>
      <header>Header</header>
      <main>
        <Outlet /> {/* Child routes render here */}
      </main>
      <footer>Footer</footer>
    </div>
  );
}

Nested routes solve a problem that becomes obvious the moment you try to avoid it: most real applications share layout — a header, a sidebar, breadcrumbs — across many “pages,” and without a nesting mechanism you end up either wrapping every route’s element in the same <Layout> component by hand (violating DRY, and worse, remounting the header on every navigation because it’s recreated fresh inside each route’s element tree) or reaching for some ad hoc shared-layout-component convention outside the router entirely. Nesting <Route> elements inverts that: a parent route renders once, its <Outlet /> marks where child content goes, and React Router swaps only the outlet’s contents as the user navigates between child routes — the <header> and <footer> in the example above never unmount or re-render just because the user went from /about to /users. The index route deserves a specific callout: <Route index element={<Home />} /> is the child that renders when the parent’s path matches exactly, with nothing further in the URL — it’s the nested-routing equivalent of index.html in a directory listing, and it’s the idiomatic way to say “this is what / shows” without giving the index route its own path (giving it one is a common mistake that produces confusing double-matching behavior). This pattern is also what makes deeply nested UI — a dashboard with a sidebar, inside which a settings section has its own tab bar, inside which individual settings panels render — tractable: each layer only owns its own slice of the shared chrome, and <Outlet /> composes them without any layer needing to know about the layers above or below it.

Data Loading

Loaders (React Router 6.4+)

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

const router = createBrowserRouter([
  {
    path: '/users/:id',
    element: <UserProfile />,
    loader: async ({ params }) => {
      const res = await fetch(`/api/users/${params.id}`);
      return res.json();
    },
  },
]);

function App() {
  return <RouterProvider router={router} />;
}
import { useLoaderData } from 'react-router-dom';

function UserProfile() {
  const user = useLoaderData();
  
  return <div>{user.name}</div>;
}

Loaders are the single biggest architectural shift React Router introduced in 6.4, and they’re worth understanding at the “why,” not just the “how,” level. Before loaders, the conventional pattern was to fetch data inside a useEffect after the component mounted — which means the browser has to: download the JavaScript bundle, parse and execute it, mount the component tree, run the effect, then start the network request for data. That sequence is a waterfall, and it’s the reason so many React apps show a loading spinner immediately followed by a second, longer loading spinner once the effect’s fetch resolves. A loader inverts the order: it’s an async function tied directly to a route, and createBrowserRouter starts running it the instant a navigation to that route begins — in parallel with fetching and rendering the route’s component code, not after. By the time React actually renders <UserProfile />, useLoaderData() already has the resolved value sitting there; there’s no loading state to manage inside the component at all for the initial data. This also changes where you handle “what if the fetch fails” — a loader that throws (as the error-handling example further down shows) is caught by the router itself and routed to an errorElement, rather than needing a try/catch and local error state inside every data-fetching component. useLoaderData() is intentionally untyped (unknown in strict setups) because the router has no way to know at compile time what a given loader returns — pairing it with a typed wrapper, as the TypeScript section later in this guide shows, is a normal and expected part of using it in a typed codebase. Note also that this pattern requires createBrowserRouter + <RouterProvider> rather than the <BrowserRouter>/<Routes>/<Route> component tree shown earlier — the two styles aren’t mixed within one router instance, and moving from one to the other is usually the first breaking change teams hit when adopting loaders in an existing app.

Form Actions

const router = createBrowserRouter([
  {
    path: '/users/:id',
    element: <EditUser />,
    loader: async ({ params }) => {
      const res = await fetch(`/api/users/${params.id}`);
      return res.json();
    },
    action: async ({ request, params }) => {
      const formData = await request.formData();
      const res = await fetch(`/api/users/${params.id}`, {
        method: 'PATCH',
        body: JSON.stringify(Object.fromEntries(formData)),
      });
      return res.json();
    },
  },
]);
import { Form, useLoaderData } from 'react-router-dom';

function EditUser() {
  const user = useLoaderData();
  
  return (
    <Form method="post">
      <input name="name" defaultValue={user.name} />
      <input name="email" defaultValue={user.email} />
      <button type="submit">Save</button>
    </Form>
  );
}

<Form> (capital F, imported from react-router-dom) looks almost identical to a plain HTML <form>, and that’s deliberate — it’s built as an enhancement of the native element rather than a replacement for it. A regular <form method="post"> submission causes the browser to do a full-page POST and reload; React Router’s <Form> intercepts that submission the same way <Link> intercepts anchor clicks, serializes the form fields into a FormData object, and routes it to the matching route’s action function instead — without a page reload, and crucially, without you needing to write an onSubmit handler, call preventDefault, manually construct the request body, or manage a isSubmitting boolean by hand (the related useNavigation hook exposes that state for you). Because the mechanism rides on top of real HTML form semantics, this pattern degrades gracefully: if JavaScript fails to load for any reason, the form still works as a genuine HTML form submission to the server, which is the “progressive enhancement” story Remix popularized and React Router inherited when the two projects merged. After an action resolves, React Router automatically re-runs every loader on the page whose data could plausibly be stale — so EditUser’s own loader, and any sibling loader for data derived from the same resource, refetch without you writing manual cache-invalidation logic. This revalidation-after-mutation behavior is the actions/loaders system’s real value proposition: it replaces a category of “did I remember to refetch after this mutation” bugs that client-side state libraries (React Query, SWR, hand-rolled Redux thunks) otherwise require explicit cache keys and invalidation calls to solve.

Error Handling

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    errorElement: <ErrorPage />,
    children: [
      {
        path: 'users/:id',
        element: <UserProfile />,
        loader: async ({ params }) => {
          const res = await fetch(`/api/users/${params.id}`);
          if (!res.ok) throw new Response('Not Found', { status: 404 });
          return res.json();
        },
      },
    ],
  },
]);

Throwing a Response from inside a loader — rather than returning an error object or null — is the idiomatic React Router pattern, and it reads strangely the first time you see it because throw normally means “something unexpectedly broke,” not “communicate an expected 404.” The reasoning is that a loader’s job is to either successfully resolve data or signal that resolution failed for a specific, structured reason (a 404 for a missing record, a 401/403 for an auth failure, a 500 for an upstream failure) — and throw is how JavaScript’s control flow already expresses “stop the normal path here.” React Router’s router catches anything thrown from a loader or action (whether it’s a Response, an Error, or an arbitrary value) and routes rendering to the nearest errorElement up the tree — “nearest,” not “the root one,” which is what makes errorElement genuinely useful for real apps: a data-loading failure three levels deep in nested routes can show an inline error just for that section, without tearing down the shared layout and navigation that wraps it. This bubbling behavior mirrors how error boundaries work in vanilla React, but extends the concept to cover loader/action failures, not just render-time exceptions.

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

function ErrorPage() {
  const error = useRouteError();
  
  if (isRouteErrorResponse(error)) {
    return (
      <div>
        <h1>{error.status} {error.statusText}</h1>
        <p>{error.data}</p>
      </div>
    );
  }
  
  return <div>Something went wrong!</div>;
}

useRouteError() is the hook counterpart to a traditional try/catch’s caught value, but because a loader, an action, or a component render could each be the source of the failure, the caught value’s shape varies — it might be a Response-derived error object (from a thrown Response), a genuine Error instance (from a thrown JavaScript error, including an unexpected exception inside a loader), or in rarer cases something else entirely if code explicitly throws a non-Error value. isRouteErrorResponse is a type guard specifically for the first case — a Response React Router itself constructed metadata around — and checking it before assuming .status/.statusText exist is what keeps this component from crashing on a plain Error (which has .message, not .status). In production, this is also the natural place to report unexpected errors to a monitoring service (Sentry, or similar): the else branch catching “something went wrong” is exactly where you’d log error before showing the generic fallback, since by definition it represents a failure mode the code didn’t anticipate strongly enough to give a specific message.

Protected Routes

import { Navigate } from 'react-router-dom';

function ProtectedRoute({ children }) {
  const { user } = useAuth();
  
  if (!user) {
    return <Navigate to="/login" replace />;
  }
  
  return children;
}

// Usage
<Route
  path="/dashboard"
  element={
    <ProtectedRoute>
      <Dashboard />
    </ProtectedRoute>
  }
/>

This ProtectedRoute pattern is the most common way to gate access in a React Router app, but it’s worth being precise about what it actually protects: it prevents the component from rendering, not the data from being requested. If Dashboard were converted to use a loader, that loader would need its own auth check — a <ProtectedRoute> wrapping the element does nothing to stop an unauthenticated loader call, since loaders and elements are evaluated somewhat independently in the router’s data flow. For that reason, most production apps push the same auth check into a loader (redirect('/login') thrown from the loader itself, rather than a wrapping component) once they adopt the data router, since it unifies the check with the data-fetching lifecycle and avoids a render-then-redirect flash where Dashboard’s shell briefly mounts before <Navigate> kicks in. The other point worth being explicit about, especially for anything handling sensitive data: this is a client-side redirect. The Dashboard component’s JavaScript, and any data embedded in the initial bundle, was already downloaded to the browser before the check ran — client-side route protection is a UX convenience that hides UI from a casual user, not a security boundary. Actual authorization has to be enforced server-side, on every API call the protected UI would make, regardless of how carefully the client-side gate is implemented.

TypeScript Integration

import { useParams, useLoaderData } from 'react-router-dom';

interface User {
  id: number;
  name: string;
  email: string;
}

function UserProfile() {
  const { id } = useParams<{ id: string }>();
  const user = useLoaderData() as User;
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

The as User cast on useLoaderData() in this example is doing real, unchecked work, and it’s worth understanding what it does and doesn’t guarantee. useLoaderData can’t be generically typed against the loader that produced the data, because the router associates loaders with routes at runtime through a plain array of route objects — there’s no type-level link the compiler can trace from loader: async () => ... back to the specific useLoaderData() call inside that route’s element. The as User assertion tells TypeScript “trust me, this is shaped like a User,” but it performs no runtime validation; if the API response shape drifts (a field gets renamed on the backend, or a new required field gets added to the User interface without a backend change), the assertion happily compiles and then fails at runtime with a Cannot read properties of undefined. Two more robust patterns are common in production codebases: writing a small typed wrapper hook per route (function useUserLoaderData() { return useLoaderData() as User; }), which at least centralizes the cast to one place, or — in the more recent React Router 7 / Remix-derived pattern — using LoaderFunctionArgs and inferred return types via typeof loader so the type flows from the loader’s actual return statement instead of being asserted by hand. For anything talking to an API whose shape isn’t fully trusted (third-party data, a service owned by a different team), pairing the loader with a runtime schema validator like Zod is worth the overhead — it turns a silent shape mismatch into an explicit, catchable error at the loader boundary instead of a confusing crash deep inside a component.

How much of React Router to adopt up front

React Router has steadily moved from “sync the URL with which component renders” toward “the URL is the source of truth for data, pending state, and errors too” — nested <Route>/<Outlet> composition solved the layout-duplication problem, loaders and actions solved the data-fetching-waterfall and cache-invalidation problems, and errorElement gave data failures the same structured, boundary-scoped handling that React error boundaries gave render failures. None of these pieces are mandatory to start with — a small app can ship with just <BrowserRouter>, <Routes>, and <Link> and add loaders later once data-fetching complexity actually justifies it — but knowing the fuller feature set changes how you’d architect a bigger app from day one, particularly around where auth checks and data validation live.


Frequently Asked Questions (FAQ)

Q. Do I need createBrowserRouter and loaders, or is <BrowserRouter>/<Routes> still fine?

A. <BrowserRouter> with <Routes>/<Route> still works and is not deprecated — it’s the right choice for apps where each route’s data-fetching is simple enough to handle with a useEffect or a library like React Query inside the component itself. Reach for createBrowserRouter and loaders specifically when you’re hitting the waterfall problem described in the Data Loading section (component mounts, then fetches) or when you want automatic revalidation after a <Form> action. The two styles use different router setups and generally aren’t mixed within a single router instance, so it’s worth deciding early rather than migrating a large route tree midway through a project.

Q. Why does my nested route’s data not update when I navigate between sibling routes with the same layout?

A. This is almost always a component-identity issue rather than a router bug. If a parent layout route renders <Outlet /> and two sibling child routes both use the same component reference for different params (for example, /users/:id reusing one UserProfile component for every user), React reuses that component instance across the navigation because, from React’s perspective, nothing about the tree shape changed — only props changed. If the component reads its data via useEffect(() => { fetch(...) }, []) with an empty dependency array, that effect won’t re-run just because id changed via useParams(). The fix is either to add the changed param to the effect’s dependency array, or — the more robust fix — move the fetch into a route loader, since loaders are keyed to the route match and correctly re-run whenever the matched params change.

Q. How does React Router compare to Next.js’s file-based App Router for a new project?

A. They solve routing for different application shapes. React Router is a client-side routing library you add to an existing React app (a Vite SPA, an Electron renderer, a component embedded in a larger non-React page) — there’s no server involved in producing HTML unless you build one yourself. Next.js’s App Router is a full framework where routing, data fetching, and server rendering are integrated by convention (folder structure defines routes, Server Components can fetch data without shipping fetch logic to the client). If SEO for public marketing pages or first-paint performance on slow connections matters, Next.js’s server rendering has a structural advantage. If you’re building something that’s inherently an authenticated, app-like SPA — an admin dashboard, an internal tool — where most content isn’t meant to be indexed anyway, React Router’s simplicity and framework-agnosticism (it doesn’t dictate your build tool, deployment target, or server) is often the better fit.