Routing with React Router

Everything so far has lived at one URL. That is fine for a widget and hopeless for an application: a user who has drilled into a product cannot bookmark it, cannot mail the link to a colleague, and loses the page the moment the back button is pressed. Routing makes the URL the source of truth for which screen is showing, without giving up the single page that makes a React application feel instant.React Router is the library almost every React application reaches for: a separate package that reads the address bar, matches it against patterns you declare, and renders the components that win. This section uses version 8.4.0, released on 15 September 2026, with React 19.3.0; Node 22.22 and React 19.2.7 are the minimum versions it installs against.Figure 2.51: A full page load versus a client-side route changeStart with the declarative pieces every mode shares; Section 2.17 continues with data routers.

2.16.1 Client-Side Routing and the History API

A router is less magical than it looks. The browser already lets a page change its address without fetching anything: history.pushState(state, "", url) adds an entry to the session history and rewrites the address bar, and history.replaceState overwrites the current entry instead. The second argument is a legacy title slot that must be empty, and the URL has to be same-origin.The other half is going backward. pushState fires no event — the code that pushed already knows — but Back and Forward fire popstate on window, carrying the state stored with that entry. A hand-rolled router is those two pieces plus a render.

const routes = { '/': () => 'Home page', '/about': () => 'About us' };

const render = () => {

const view = routes[location.pathname] || (() => '404: ' + location.pathname);

document.querySelector('#app').textContent = view();

};

document.addEventListener('click', (e) => {

const a = e.target.closest('a[href^="/"]');

if (!a || e.metaKey || e.ctrlKey || a.target) return; // let the browser handle these

e.preventDefault(); // no navigation, no reload

history.pushState({}, '', a.getAttribute('href')); // address bar only

render();

});

window.addEventListener('popstate', render); // Back and Forward

render();

Listing 2.130: A router in a dozen lines, with no library at all That is the whole idea, and also why you should not ship it. Those lines have no pattern matching, so /products/42 cannot be a route; no nesting, so a shared layout re-renders from scratch; no relative links; no scroll restoration; and the click handler quietly breaks download and rel="external". React Router is what this grows into once every case is handled.A newer API, the Navigation API, models navigation directly — navigation.navigate(), an intercepting navigate event, a real list of entries — and became Baseline in January 2026. React Router still builds on history.

2.16.2 Installing React Router

One package covers the browser, the server and React Native: npm install react-router@8.4.0 Listing 2.131: Adding React Router to a Vite project That adds four packages in all: React Router 8 ships only cookie-es and @remix-run/route-pattern of its own, and React and React DOM are peer dependencies, so npm refuses the install below React 19.2.7. The package publishes three entry points: react-router, react-router/dom and react-router/route-pattern.For years the browser package was react-router-dom, a thin re-export plus the DOM-only pieces. Version 8 deletes it. It stopped at 7.18.4 and will never get an 8.x release, so any tutorial or package.json that installs it is pre-v8. The migration is mechanical:

import { Link, Routes, Route, useNavigate } from 'react-router-dom'; // v6 and v7

import { Link, Routes, Route, useNavigate } from 'react-router'; // v8

import { RouterProvider } from 'react-router/dom'; // the one exception

Listing 2.132: The one import change that v8 forces RouterProvider is the exception worth memorizing. The copy in react-router/dom is wired to React DOM's flushSync, which the router needs to make view transitions and some pending states land in a single paint; the copy in react-router is for non-DOM renderers. Import it from the wrong place and everything still works, just less smoothly — a bug that never produces an error message.Upgrade an older code base v6 to v7 first, adopting the v8_middleware, v8_splitRouteModules and v8_viteEnvironmentApi future flags one at a time, then rename. From v5 it is a rewrite: <Switch>, <Route render>, useHistory and the match prop are all gone.

2.16.3 Declarative, Data and Framework Modes

React Router 8 is three products in one package, layered so each mode is the previous one plus more. Which mode you are in is decided by a single line: how the router gets into your tree. Mode How the router is created Adds Declarative <BrowserRouter> wrapping <Routes> URL matching, links, hooks Data createBrowserRouter([...]) + <RouterProvider> loaders, actions, fetchers, pending UI Framework routes.ts + the @react-router/dev Vite plugin typed routes, code splitting, SSR/SSG Table 2.24: The three modes of React Router 8 Everything in this section is declarative mode, because that is where the vocabulary lives: <Route>, path patterns, <Link>, useParams, useNavigate and useLocation behave identically in all three modes, so a component written against them is portable. What declarative mode cannot do is load data — the loader/action family, useFetcher, useNavigation and useBlocker all throw outside a data router, so you fetch in effects as in Section 2.4.4. Framework mode goes further and owns the build too, making it a competitor to Next.js (Chapter 5).Pick declarative mode when something else — TanStack Query, Redux — already does the fetching, and data mode the moment you catch yourself writing the same "loading, error, data" effect in every screen. Section 2.17 shows that upgrade: the same route tree, written as objects instead of JSX.

2.16.4 Link

<Link> is the navigation primitive. It renders a real <a href> and calls preventDefault() on the click, so a route change costs a re-render instead of a page load while screen readers still announce a link and Ctrl-click still opens a tab. Use it for every in-app destination and a plain <a> for anything that leaves the app.to takes a string or a partial Path object. A string is resolved against the current route: a leading slash means "from the root", no leading slash "relative to the route that rendered this link", and .. climbs one matched route — not one URL segment, unless you add relative="path".

<Link to="/courses?sort=name">Courses</Link> {/* absolute */}

<Link to="chemistry">Chemistry</Link> {/* relative to this route */}

<Link to=".." relative="path">Up one URL segment</Link>

<Link to={{ pathname: '/courses', search: '?sort=name', hash: '#top' }}>Object</Link>

<Link to="/login" replace>Log in</Link> {/* no new history entry */}

<Link to="/img/4" state={{ background: location }}>Open</Link> {/* off-URL payload */}

Listing 2.133: The forms of to, plus replace and state replace keeps a login page or a redirect out of the back button's way. state is read back with useLocation().state; it survives a reload but not a paste into a new tab, so use it for hints ("you came from the gallery"), never for data the page needs. Two React Router 5 habits are now errors: to can no longer be a function of the current location, and there is no component prop — <Link> forwards its ref to the <a>, so wrap it rather than replace it. Every other prop lands on the <a> untouched. Warning: A <Link> rendered outside a router throws useHref() may be used only in the context of a <Router> component, the most common React Router error. It usually means a test rendered the component directly; wrap it in <MemoryRouter> (Section 2.16.10).

2.16.5 NavLink and Custom Links

A navigation bar has to show where you are. <NavLink> is <Link> plus that knowledge: it compares its to against the current location and, on a match, adds the class active and the attribute aria-current="page". Two more classes appear in data and framework modes — pending while a loader for the destination is running, transitioning during a view transition. For anything they cannot express, className, style and children each accept a function receiving { isActive, isPending, isTransitioning }.Matching is by prefix, so <NavLink to="/tasks"> stays active on /tasks/7 — what a section tab wants and never what a "Home" link wants, since every URL starts with /. end restricts the match to the exact path.

<style>body{font:15px/1.6 system-ui;margin:12px}nav a{margin-right:14px;text-decoration:none}

a.active{color:#b3261e;font-weight:700}</style>

<div id="root"></div>

<script type="importmap">{"imports":{"react":"https://esm.sh/react@19.3.0",

"react-dom/client":"https://esm.sh/react-dom@19.3.0/client?external=react",

"react-router":"https://esm.sh/react-router@8.4.0?external=react"}}</script>

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>

<script type="text/babel" data-type="module">

import React from 'react';

import { createRoot } from 'react-dom/client';

import { MemoryRouter, Routes, Route, NavLink, Link, useMatch } from 'react-router';

function Tab({ to, end, children }) { // a hand-built NavLink

const match = useMatch(end ? to : `${to}/*`); // a trailing /* makes it a prefix match

return <Link to={to} style={{ color: match ? '#1565c0' : '#444' }}>

{match ? '› ' : ''}{children}</Link>;

}

function App() {

return <MemoryRouter initialEntries={['/tasks/7']}>

<nav><NavLink to="/" end>Home</NavLink><NavLink to="/tasks">Tasks</NavLink>

<NavLink to="/help">Help</NavLink></nav>

<nav><Tab to="/" end>Home</Tab> <Tab to="/tasks">Tasks</Tab></nav>

<Routes>

<Route path="/" element={<h3>Home</h3>} />

<Route path="/tasks/:id" element={<h3>Task detail</h3>} />

</Routes>

</MemoryRouter>;

}

createRoot(document.getElementById('root')).render(<App />);

</script>

Listing 2.134: NavLink's active class, and the same idea hand-built with useMatch Figure 2.52: Browser output of Listing 2.134The second row is the custom-link pattern: useMatch(pattern) tests any pattern against the current URL and returns a match object or null, so a breadcrumb, a highlighted sidebar row or a disabled tab can be built from the same signal <NavLink> uses internally. Build your own only when the match drives something other than styling — a marker glyph, aria-disabled, a badge — because <NavLink> gets aria-current right and a hand-built link will not unless you remember it.

2.16.6 Routes and Route

<Routes> looks at every <Route> beneath it and renders exactly one: the best match for the current location, not the first match, and never several at once. <Route> does nothing outside a <Routes> — it is a configuration object wearing JSX syntax, not a component that renders.

<Routes>

<Route path="/" element={<Home />} />

<Route path="/products" element={<Catalog />} />

<Route path="/products/:sku" element={<Product />} />

<Route path="/docs/*" element={<Docs />} />

<Route path="*" element={<NotFound />} />

</Routes>

Listing 2.135: A route table Path patterns have four pieces. A literal segment matches itself; :sku is a dynamic segment, readable with useParams, and a ? suffix such as :page? makes it optional; a trailing /* is a splat that swallows the rest of the URL; and * alone matches everything, which is how the last row becomes a 404. Regular-expression constraints — the old :id(\d+) syntax — are gone. Besides path and element, a <Route> takes Component={Product} in place of element, index for the child that fills the parent's own URL (Section 2.16.7), and caseSensitive; omitting path makes a pathless layout route."Best match" is scored, not ordered: static segments beat dynamic ones, dynamic ones beat splats, and a longer pattern beats a shorter one. So /products/new reaches a path="/products/new" route even when /products/:sku is written above it; row order is a readability choice. Gone since v6: exact and strict (matching is exact now, which is why <Switch> was renamed <Routes>), the per-route location override, the render and children render props, and an array of paths in one path. The rendered component also receives no props — no match, no location, no history. Call the hooks.

2.16.7 Nested Routes and Outlet

Real applications are not a flat list of pages: a settings area has a shared header and a tab strip around a changing panel. Nest the <Route> elements and the URL segments nest with them — the parent renders its layout and puts <Outlet /> wherever the matched child belongs. React Router renders every matched route in the chain, parent first, so a deep URL mounts the whole stack at once and only the changed leaf re-renders next time.Two extras make nesting comfortable: a child <Route index> fills the outlet at the parent's exact URL — the "nothing selected yet" panel — and a link written without a leading slash resolves against the route that rendered it, so the parent never has to know its own prefix.

<style>body{font:15px/1.6 system-ui;margin:12px}a{margin-right:12px}

.box{border:1px solid #90a4ae;padding:8px 12px;border-radius:6px;margin-top:8px}</style>

<div id="root"></div>

<script type="importmap">{"imports":{"react":"https://esm.sh/react@19.3.0",

"react-dom/client":"https://esm.sh/react-dom@19.3.0/client?external=react",

"react-router":"https://esm.sh/react-router@8.4.0?external=react"}}</script>

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>

<script type="text/babel" data-type="module">

import React from 'react';

import { createRoot } from 'react-dom/client';

import { MemoryRouter, Routes, Route, Link, Outlet, useParams,

useLocation } from 'react-router';

function Topics() { // the parent, always on screen

return <div className="box"><b>Topics</b> (parent route)

<div><Link to="jsx">jsx</Link><Link to="hooks">hooks</Link></div>

<Outlet /> {/* the matched child lands here */}

</div>;

}

function Topic() { return <div className="box">Topic: {useParams().topicId}</div>; }

function Url() { return <code>{useLocation().pathname}</code>; }

function App() {

return <MemoryRouter initialEntries={['/topics']}>

<div className="box">URL: <Url /></div>

<Routes>

<Route path="/topics" element={<Topics />}>

<Route index element={<div className="box">Pick a topic.</div>} />

<Route path=":topicId" element={<Topic />} />

</Route>

</Routes>

</MemoryRouter>;

}

createRoot(document.getElementById('root')).render(<App />);

setTimeout(() => document.querySelectorAll('a')[1].click(), 300); // click "hooks"

</script>

Listing 2.136: A layout route, an index route, and relative links Figure 2.53: Browser output of Listing 2.136A parent with no path of its own is a layout route: it groups children under a shared element without adding a URL segment, which is how a guard (Section 2.16.17) is expressed. Prefer the outlet to repeating a wrapper in every child — it is what lets the parent keep scroll position, open menus and local state across child navigations.

2.16.8 BrowserRouter

Underneath <Routes> there has to be a router: the component that owns the current location, publishes it through context, and knows how to change it. <BrowserRouter> is the one you want in a browser. It drives the History API directly, so the address bar shows clean paths such as example.com/products/42, and Back and Forward work because they are the browser's history. Every later subsection assumes this setup.

createRoot(document.getElementById('root')).render(

<BrowserRouter basename="/shop">

<App />

</BrowserRouter>

);

Listing 2.137: Mounting the app inside a browser router Render exactly one router, at the top of the tree, outside anything that re-renders often. basename is the prefix every path is relative to, for an app served from a subdirectory: with basename="/shop", a <Link to="/cart"> produces the href /shop/cart, and a URL outside /shop matches nothing.The price of clean paths is server configuration. On a reload or a pasted link the browser sends GET /products/42 to your host, and unless the host answers with your index.html the user gets a 404 before React ever runs; Section 2.16.11 fixes that. Three props are gone: forceRefresh, getUserConfirmation (now useBlocker, in data mode) and keyLength. There is no importable history object either — call useNavigate in a component.

2.16.9 HashRouter

<HashRouter> keeps the route in the fragment: example.com/#/person/john. That single # changes everything about deployment, because a fragment is never sent to the server. The host sees a request for example.com/ and returns the one HTML file it has; the router reads window.location.hash from there. No rewrite rules at all.Swap it in wherever the last subsection put <BrowserRouter>; it takes the same basename and children. That makes it right in a narrow set of cases: a demo on GitHub Pages or in an S3 bucket, an app on a CDN you cannot configure, a page opened from the filesystem, and an Electron shell where file:// paths make the History API awkward.Everywhere else, prefer <BrowserRouter>. Hash URLs are worse for search engines and link previews, the fragment never reaches the server so server rendering cannot know which route to render, and its original job — jumping to an anchor — collides with the router's use of it. hashType, which chose between #/path, #path and #!/path, is gone; the format is fixed at #/path. Did you know? Before pushState, every single-page framework routed through window.location.hash, and Google published a #! "hashbang" convention so crawlers could request an escaped-fragment version of the page. It was deprecated in October 2015; its remains are why hashType existed.

2.16.10 MemoryRouter and StaticRouter

The remaining two routers exist for environments with no address bar. <MemoryRouter> keeps the history stack in a plain array. initialEntries seeds that array and initialIndex picks which entry is current, so a test can start at any URL — or mid-stack, with entries behind it, to assert that Back behaves. It is the router for Vitest and React Testing Library (Section 2.15.4), for Storybook, and for React Native.

test('shows the product for the :sku in the URL', () => {

render(

<MemoryRouter initialEntries={['/cart', '/products/A-42']} initialIndex={1}>

<Routes><Route path="/products/:sku" element={<Product />} /></Routes>

</MemoryRouter>

);

expect(screen.getByRole('heading')).toHaveTextContent('A-42');

});

Listing 2.138: Rendering a routed component in a test <StaticRouter> goes one step further: its location never changes at all. On the server there is exactly one URL per request and nobody clicking around, so a router that cannot navigate is the honest model. In an Express handler you wrap the app in <StaticRouter location={req.url}> and pass that to renderToString.It now takes only basename, location and children. The context object React Router 5 used to smuggle a status code or redirect target back out of the render is gone, so it cannot tell you the app wanted a 404 or a 302 — one reason server rendering has moved to the data routers of Section 2.17.

2.16.11 Serving a Single-Page App on Refresh

Client-side routing only works once the client is running. Press F5 on example.com/products/42, or paste that link into a fresh tab, and the browser asks the server for /products/42 — a path that exists in your route table and nowhere on disk. A static host answers 404, and the bug looks bizarre because in-app navigation to that same page works perfectly.The fix is a history-API fallback: for any request that does not match a real file, serve index.html and let the router sort it out. Vite's dev server does this automatically, so the failure first shows up on deploy.

location / {

try_files $uri $uri/ /index.html;

}

location /assets/ {

try_files $uri =404; # hashed bundles: 404 instead of silently serving HTML

}

Listing 2.139: Nginx: try the file, then the directory, then the app Every other host says the same thing in its own dialect. In Express, mount express.static('dist') first and end with app.get('*splat', (req, res) => res.sendFile(path.resolve('dist/index.html'))). Apache needs a .htaccess with RewriteCond %{REQUEST_FILENAME} !-f and RewriteRule ^ index.html [L]; Netlify a _redirects file containing /* /index.html 200; Vercel a rewrites entry in vercel.json; S3 behind CloudFront a custom error response mapping 403 and 404 to /index.html with status 200.The second location block is the part people forget. A blanket fallback also catches typos in bundle URLs, so a missing /assets/index-a1b2.js is served as HTML and the browser reports "Unexpected token '<'" from a JavaScript file. Exclude the asset directory and the API prefix, and keep the fallback uncached: no-cache on index.html, immutable caching on the hashed assets.

2.16.12 useLocation and useSearchParams

useLocation() returns the current location object and re-renders the component on every navigation. It has five fields: pathname, search (the query string, leading ? included), hash, state (whatever a <Link> or navigate call attached, or null), and key, a unique id per history entry that makes a fine cache key or a useEffect dependency for "run again on every navigation".The query string is not parsed for you: search is a string and URLSearchParams is the browser's parser. useSearchParams wraps reading and writing it in one hook shaped like useState.

function Catalog() {

const [params, setParams] = useSearchParams({ sort: 'name' }); // default, not in the URL

const tags = params.getAll('tag'); // ?tag=a&tag=b

return <>

<select value={params.get('sort')}

onChange={(e) => setParams({ sort: e.target.value })}>

<option value="name">Name</option><option value="price">Price</option>

</select>

<button onClick={() => setParams((p) => { p.delete('tag'); return p; },

{ replace: true })}>Clear {tags.length} tags</button>

</>;

}

Listing 2.140: Filters that live in the URL The setter accepts a string, an object, an array of pairs, a URLSearchParams, or a function that receives the current params and returns them mutated — use the functional form whenever the new value depends on the old. Its second argument takes replace, state and preventScrollReset. Pass replace: true for anything a user adjusts repeatedly, such as a slider or a search box; otherwise every keystroke becomes a history entry and Back turns into an undo log.Filters, sort order and pagination in the query string cost one hook and buy a shareable, bookmarkable, reload-proof view. Keep private UI state — a dropdown's open flag, an unsaved draft — in useState.

2.16.13 useParams

A dynamic segment is a hole in a pattern: :sku in /products/:sku matches one URL segment and remembers it under that name. useParams() returns every param matched by the current route and its ancestors, so a component nested three routes deep sees the parent's :orgId alongside its own :taskId without props.

<style>body{font:15px/1.7 system-ui;margin:12px}

code{background:#eceff1;padding:1px 5px;border-radius:4px}</style>

<div id="root"></div>

<script type="importmap">{"imports":{"react":"https://esm.sh/react@19.3.0",

"react-dom/client":"https://esm.sh/react-dom@19.3.0/client?external=react",

"react-router":"https://esm.sh/react-router@8.4.0?external=react"}}</script>

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>

<script type="text/babel" data-type="module">

import React from 'react';

import { createRoot } from 'react-dom/client';

import { MemoryRouter, Routes, Route, useParams, useLocation } from 'react-router';

function Show() { // one component, three URLs

return <p><code>{useLocation().pathname}</code> &rarr;{' '}

<code>{JSON.stringify(useParams())}</code></p>;

}

function At({ url }) {

return <MemoryRouter initialEntries={[url]}>

<Routes>

<Route path="/docs/*" element={<Show />} />

<Route path="/:id/:page?" element={<Show />} />

</Routes>

</MemoryRouter>;

}

createRoot(document.getElementById('root')).render(<>

<At url="/netflix" />

<At url="/yahoo/invoices" />

<At url="/docs/api/hooks" />

</>);

</script>

Listing 2.141: What useParams returns for three different patterns Figure 2.54: Browser output of Listing 2.141Three things to read off that output. Every value is a string — /products/42 gives you "42", so call Number() before arithmetic. An optional segment that did not match is absent from the object rather than undefined. And a splat route stores the unmatched remainder under the key *, slashes included, which is how a docs viewer takes over a whole subtree.Going the other way, generatePath('/user/:id/:entity', { id: 1, entity: 'posts' }) returns /user/1/posts; prefer it to a template literal, because it encodes the values and fails loudly on a missing one. Validate params in the component, not in the pattern — a bad :sku reaches your code, and you decide whether that means an empty state, a <Navigate>, or a thrown error for the nearest boundary.

2.16.14 useNavigate

Some navigations have no link to click: a form that just saved, a session that just expired, a countdown that reaches zero. useNavigate() returns a function that changes the location programmatically.

const navigate = useNavigate();

async function onSubmit(e) {

e.preventDefault();

const { id } = await createProduct(new FormData(e.currentTarget));

navigate(`/products/${id}`, { replace: true, state: { justCreated: true } });

}

Listing 2.142: Navigating after a successful save navigate(to) pushes a new entry. navigate(to, { replace: true }) overwrites the current one — the right call after a save, so Back does not return to a form already submitted. navigate(n) with a number moves through the stack instead: navigate(-1) is Back, navigate(1) is Forward. The options also take state, preventScrollReset and relative, and to may be relative just as <Link to> is.React Router 5 handed a single mutable history object to every routed component. Version 8 has no such object; each of its jobs became a hook. push, replace, goBack and goForward are the navigate calls above; history.location is useLocation(), history.action is useNavigationType(), history.block is useBlocker() in data mode only, and history.length is no longer exposed at all.Two traps. Do not call navigate during render — it is a side effect, and React will warn; navigate from an event handler, or render a <Navigate> element (Section 2.16.16). And do not confuse useNavigate with useNavigation: the first starts a navigation, the second describes one already in flight.

2.16.15 useMatch

useMatch(pattern) asks a question the other hooks cannot: does the current URL match this pattern, whether or not any <Route> was declared for it? It returns null for no match, or a match object — for /:id/:page? against /netflix, that is params: { id: 'netflix' }, pathname and pathnameBase both /netflix, and the resolved pattern. matchPath(pattern, pathname) is the same matcher as a plain function, usable outside React entirely — in a loader, a test, a service worker.Use it for UI that must react to the URL without being routed by it: the custom link of Section 2.16.5, a breadcrumb trail, a header that hides itself on /checkout/*. Patterns are exact by default, so append /* for a prefix test. And remember it matches the URL, not the route tree — useMatch('/tasks') is null on /tasks/7 even though the /tasks route is very much rendered.The removed useRouteMatch() did double duty in React Router 5: testing arbitrary patterns, and handing back the current route's path and url for concatenating nested routes and links. Only the first job survives; nested <Route> elements and relative links replaced the second.

2.16.16 Navigate, Redirects and No Match Routes

<Navigate> is useNavigate as an element: rendering it performs the navigation in an effect, which is what you need when the decision belongs to the render tree rather than to an event handler. Two patterns cover most redirects. A no-match route is a <Route path="*">: it matches every URL but, because matching is scored rather than ordered, always loses to a more specific route, so it fires only when nothing else matched. An old URL gets a <Route> whose element is a <Navigate>, keeping stale links alive without a server rule.

<Routes>

{/* ...the route table of Section 2.16.6, plus these two rows */}

<Route path="/old-docs/*" element={<Navigate to="/docs" replace />} />

<Route path="*" element={<NotFound />} />

</Routes>

function NotFound() {

const { pathname } = useLocation();

return <><h3>No page at <code>{pathname}</code></h3><Link to="/">Home</Link></>;

}

Listing 2.143: A 404 route and a moved URL replace is nearly always right on a redirect. Without it the URL you are bouncing away from stays in history, so Back lands on it, the redirect fires again, and the user is trapped. <Navigate> also takes state and relative; the from prop of React Router 5 is gone. Warning: A client-side 404 page still returns HTTP 200, because the server sent index.html happily (Section 2.16.11), and crawlers treat that as a real page. The status code has to come from the server or from framework mode — <Navigate> cannot set it. For a redirect that depends on data rather than on the URL, render nothing and navigate — a component returning <Navigate to="/login" replace /> when a check fails is the whole guard in the next subsection.

2.16.17 Protected Routes

Guarding a page is the same redirect as the last subsection, decided by a condition instead of by the URL. The idiom is a layout route: a pathless <Route> whose element checks the condition and renders either <Outlet /> — letting the matched child through — or a <Navigate> to the login page. Everything nested under it is protected by construction, so adding a route to the group is a one-line change. Two details make the experience right: state={{ from: location }} tells the login page where the user was headed, and replace on both navigations keeps neither the guarded URL nor the login page in history for Back to find.

<style>body{font:15px/1.6 system-ui;margin:12px}a{margin-right:12px}

.box{border:1px solid #90a4ae;padding:6px 10px;border-radius:6px;margin-top:8px}</style>

<div id="root"></div>

<script type="importmap">{"imports":{"react":"https://esm.sh/react@19.3.0",

"react-dom/client":"https://esm.sh/react-dom@19.3.0/client?external=react",

"react-router":"https://esm.sh/react-router@8.4.0?external=react"}}</script>

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>

<script type="text/babel" data-type="module">

import React, { createContext, useContext, useState } from 'react';

import { createRoot } from 'react-dom/client';

import { MemoryRouter, Routes, Route, Link, Outlet, Navigate,

useNavigate, useLocation } from 'react-router';

const Auth = createContext(null);

function RequireAuth() { // a layout route, not a wrapper

const { user } = useContext(Auth);

const location = useLocation();

if (!user) return <Navigate to="/login" state={{ from: location }} replace />;

return <Outlet />; // matched child route renders here

}

function Login() {

const { signIn } = useContext(Auth), navigate = useNavigate();

const from = useLocation().state?.from?.pathname || '/'; // where we were headed

return <div className="box">You must log in to see {from}{' '}

<button onClick={() => { signIn('ada'); navigate(from, { replace: true }); }}>

Log in</button></div>;

}

function App() {

const [user, setUser] = useState(null);

return <Auth value={{ user, signIn: setUser }}>

<MemoryRouter>

<nav><Link to="/">Public</Link><Link to="/reports">Reports</Link>

user: <b>{user || 'anonymous'}</b></nav>

<Routes>

<Route path="/" element={<h3>Public page</h3>} />

<Route path="/login" element={<Login />} />

<Route element={<RequireAuth />}>

<Route path="/reports" element={<h3>Reports (protected)</h3>} />

</Route>

</Routes>

</MemoryRouter>

</Auth>;

}

createRoot(document.getElementById('root')).render(<App />);

setTimeout(() => document.querySelectorAll('nav a')[1].click(), 200); // Reports

setTimeout(() => document.querySelector('button').click(), 400); // Log in

</script>

Listing 2.144: A layout route that guards its children, with a round trip through login Figure 2.55: Browser output of Listing 2.144The screenshot follows both scripted clicks: Reports while anonymous bounced to the login page, and logging in returned to /reports. Replace useState with your real session — a token in memory, a useSyncExternalStore over an auth SDK (Section 2.8.8) — and the shape holds. While that session is still loading, return a placeholder from the guard rather than <Navigate>, or every reload flashes the login page. Warning: This is a routing concern, not a security one. Anyone can edit the JavaScript, so a guarded route hides UI and nothing more. Every protected endpoint must check authorization on the server too — Chapter 3 covers that in Express middleware.