Step 15 of 21
Routing
Turn a single React app into many pages: URLs, nested layouts, params, search params, loaders and code splitting.
A real app has more than one screen: a list, a detail page, settings, a 404. Users expect each screen to have its own URL they can bookmark, share and reach with the back button. A router maps the URL to the components that render, and changes the URL without reloading the page. This lesson uses React Router (the code works with versions 7 and 8), the most widely used router, and ends with how TanStack Router differs.
What a client-side router does
In a traditional multi-page site, every link click asks the server for a new HTML document. The browser throws away the current page, including all JavaScript state, and starts again.
A single-page app (SPA) loads one HTML document and one bundle. When the user clicks a link, the router:
- Cancels the browser's default navigation.
- Calls
history.pushState()to change the URL without a request. - Re-renders the components that match the new URL.
When the user presses back, the browser fires a popstate event and the router renders whatever matches the previous URL. The page never reloads, so state that lives above the router (an open audio player, a cached query) survives navigation.
There is one catch. If a user reloads /projects/42, the browser asks the server for /projects/42. A static host has no such file, so you must configure it to serve index.html for any unknown path. Vite's dev server does this already; Netlify, Vercel, Cloudflare Pages and nginx (try_files $uri /index.html;) all need a one-line rule.
Defining routes
Install the package:
npm install react-routerSince version 7, React Router ships everything from react-router (the old react-router-dom package is now just a re-export). Define routes as a tree of objects and hand them to RouterProvider:
// main.tsx
import { createRoot } from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router'
import { RootLayout } from './routes/root-layout'
import { HomePage } from './routes/home'
import { ProjectsPage } from './routes/projects'
import { ProjectPage } from './routes/project'
import { NotFound } from './routes/not-found'
const router = createBrowserRouter([
{
path: '/',
Component: RootLayout,
children: [
{ index: true, Component: HomePage },
{ path: 'projects', Component: ProjectsPage },
{ path: 'projects/:projectId', Component: ProjectPage },
{ path: '*', Component: NotFound },
],
},
])
createRoot(document.getElementById('root')!).render(<RouterProvider router={router} />)index: truemarks the child that renders at the parent's exact path (/).:projectIdis a dynamic segment: it matches any single segment and exposes it as a param.*matches anything not matched above, which makes it your 404 page.
Layouts and <Outlet>
Most pages share a header, navigation and footer. A layout route renders that shell once, and an <Outlet /> where the matched child goes:
// routes/root-layout.tsx
import { NavLink, Outlet } from 'react-router'
export function RootLayout() {
return (
<div className="app">
<nav>
<NavLink to="/" end>Home</NavLink>
<NavLink to="/projects">Projects</NavLink>
</nav>
<main>
<Outlet />
</main>
</div>
)
}NavLink is a Link that knows whether it's active. It adds aria-current="page" and an active class when its route matches, so you can style the current tab with nav a.active { ... }. The end prop on the home link stops it matching every URL that starts with /.
Layouts nest. A settings area with its own sidebar is a child route with its own Component and its own <Outlet />. Navigating between two settings tabs re-renders only the inner outlet; the outer layout and its state stay mounted.
Reading the URL: params and search params
Path params identify which resource. Read them with useParams:
// routes/project.tsx
import { useParams } from 'react-router'
export function ProjectPage() {
const { projectId } = useParams()
return <h1>Project {projectId}</h1>
}Params are always strings (or undefined). If you need a number, convert and validate: Number(projectId) might be NaN when someone types /projects/abc.
Search params (?status=open&page=2) describe how to view it: filters, sorting, pagination, the selected tab. Keeping them in the URL instead of useState means a reload, a shared link or the back button restores the exact view.
import { useSearchParams } from 'react-router'
export function ProjectsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const status = searchParams.get('status') ?? 'all'
return (
<select
value={status}
onChange={(e) =>
setSearchParams((prev) => {
prev.set('status', e.target.value)
prev.delete('page') // a new filter starts at page 1
return prev
})
}
>
<option value="all">All</option>
<option value="open">Open</option>
<option value="archived">Archived</option>
</select>
)
}A useful rule: if a user would be annoyed to lose it on reload, it belongs in the URL.
To navigate from code, after a form submits or a dialog confirms, use useNavigate:
const navigate = useNavigate()
// after creating a project:
navigate(`/projects/${created.id}`)
// replace the history entry instead of pushing one:
navigate('/login', { replace: true })Loading data with loaders
In the data fetching lesson you fetched inside components. That has a cost: the route's code downloads, the component renders, then the request starts. Nested routes make it worse: the parent fetches, renders its child, then the child fetches. This is a request waterfall.
A route loader starts fetching as soon as navigation begins, for every matched route in parallel:
import { useLoaderData, type LoaderFunctionArgs } from 'react-router'
type Project = { id: string; name: string; status: string }
export async function projectLoader({ params }: LoaderFunctionArgs) {
const res = await fetch(`/api/projects/${params.projectId}`)
if (res.status === 404) throw new Response('Not found', { status: 404 })
if (!res.ok) throw new Error(`Failed to load project (${res.status})`)
return (await res.json()) as Project
}
export function ProjectPage() {
const project = useLoaderData<typeof projectLoader>()
return <h1>{project.name}</h1>
}{
path: 'projects/:projectId',
Component: ProjectPage,
loader: projectLoader,
ErrorBoundary: ProjectError,
}By default the router keeps showing the old page until the loader resolves. Show progress with useNavigation(), whose state is 'loading' during a transition. If a loader throws, the nearest ErrorBoundary renders instead, and inside it useRouteError() returns what was thrown. Throwing a Response with a 404 status lets the boundary show "Project not found" instead of a generic error; check it with isRouteErrorResponse(error).
Loaders and TanStack Query work well together: the loader calls queryClient.ensureQueryData(...) to start the fetch early, and the component uses useQuery with the same key to get caching and background refetching.
Code splitting by route
Without splitting, every page's code is in one bundle that the user downloads before seeing anything. Routes are the natural place to split, because a user only needs the code for the page they're on.
React Router can load a route's module on demand with lazy:
{
path: 'reports',
lazy: async () => {
const { ReportsPage, reportsLoader } = await import('./routes/reports')
return { Component: ReportsPage, loader: reportsLoader }
},
}Vite turns each dynamic import() into a separate chunk. The router downloads the chunk and runs the loader as part of the same navigation, so splitting doesn't add a waterfall. Split heavy, rarely visited routes (admin panels, charts, editors) first; splitting a 3 KB page saves nothing.
Protecting routes
A route that needs a signed-in user can check in its loader and redirect:
import { redirect } from 'react-router'
export async function requireUser({ request }: LoaderFunctionArgs) {
const res = await fetch('/api/session')
if (res.status === 401) {
const from = new URL(request.url).pathname
throw redirect(`/login?next=${encodeURIComponent(from)}`)
}
return res.json()
}Put it on a layout route that wraps every private page, so one check guards them all. Remember that this only improves the experience: anyone can change client-side code. The server must check authorisation on every API request. A client-side guard only stops a signed-out user from seeing an empty dashboard.
React Router or TanStack Router?
Both are solid. They differ in how much they lean on TypeScript.
| React Router | TanStack Router | |
|---|---|---|
| Popularity | The default for a decade; most tutorials and codebases | Newer, growing fast |
| Type safety | Params are string | undefined; types via generated helpers in framework mode | Fully typed paths, params and search params; a typo in to is a compile error |
| Search params | URLSearchParams you parse yourself | Validated with a schema (for example Zod) and typed |
| Route definitions | Objects or files (framework mode) | Files with code generation, or code |
| Full framework | React Router framework mode (formerly Remix) | TanStack Start |
The concepts in this lesson (nested layouts, outlets, params, loaders, lazy routes, redirects) exist in both, under almost the same names. Learn them once and switching is an afternoon's work.
Practice
Build a small "projects" app with Vite and React Router:
- A root layout with navigation (Home, Projects) that highlights the active link.
/projectslists projects from a hard-coded array, filterable by?status=open|archived|allstored in the URL./projects/:projectIduses a loader that throws a 404Responsefor unknown IDs, with anErrorBoundarythat shows "Project not found".- A
/reportsroute that's lazy-loaded. Confirm in the Network tab that its chunk only downloads when you visit it.
Show solution
// main.tsx
import { createRoot } from 'react-dom/client'
import {
createBrowserRouter, RouterProvider, NavLink, Outlet, Link,
useLoaderData, useSearchParams, useRouteError, isRouteErrorResponse,
type LoaderFunctionArgs,
} from 'react-router'
type Project = { id: string; name: string; status: 'open' | 'archived' }
const PROJECTS: Project[] = [
{ id: '1', name: 'Website redesign', status: 'open' },
{ id: '2', name: 'Mobile app', status: 'open' },
{ id: '3', name: 'Old CRM', status: 'archived' },
]
function RootLayout() {
return (
<>
<nav>
<NavLink to="/" end>Home</NavLink> <NavLink to="/projects">Projects</NavLink>{' '}
<NavLink to="/reports">Reports</NavLink>
</nav>
<main><Outlet /></main>
</>
)
}
function Projects() {
const [params, setParams] = useSearchParams()
const status = params.get('status') ?? 'all'
const visible = PROJECTS.filter((p) => status === 'all' || p.status === status)
return (
<>
<select value={status} onChange={(e) => setParams({ status: e.target.value })}>
<option value="all">All</option>
<option value="open">Open</option>
<option value="archived">Archived</option>
</select>
<ul>
{visible.map((p) => (
<li key={p.id}><Link to={`/projects/${p.id}`}>{p.name}</Link></li>
))}
</ul>
</>
)
}
async function projectLoader({ params }: LoaderFunctionArgs) {
const project = PROJECTS.find((p) => p.id === params.projectId)
if (!project) throw new Response('Not found', { status: 404 })
return project
}
function ProjectPage() {
const project = useLoaderData<typeof projectLoader>()
return <h1>{project.name} ({project.status})</h1>
}
function ProjectError() {
const error = useRouteError()
if (isRouteErrorResponse(error) && error.status === 404) return <p>Project not found.</p>
return <p>Something went wrong.</p>
}
const router = createBrowserRouter([
{
path: '/',
Component: RootLayout,
children: [
{ index: true, element: <h1>Home</h1> },
{ path: 'projects', Component: Projects },
{ path: 'projects/:projectId', Component: ProjectPage, loader: projectLoader, ErrorBoundary: ProjectError },
{
path: 'reports',
lazy: async () => ({ Component: (await import('./reports')).ReportsPage }),
},
{ path: '*', element: <h1>Page not found</h1> },
],
},
])
createRoot(document.getElementById('root')!).render(<RouterProvider router={router} />)// reports.tsx
export function ReportsPage() {
return <h1>Reports</h1>
}Open DevTools → Network, reload on /, and you'll see no reports-*.js request until you click Reports.
Key takeaways
- A client-side router changes the URL with
pushStateand renders the matching components without a page reload. Configure your host to serveindex.htmlfor unknown paths. - Layout routes render shared UI once and put the matched child in
<Outlet />. Navigate withLinkorNavLink, never a bare<a>. - Path params say which resource; search params hold view state (filters, pages, tabs) so reloads and shared links keep it.
- Loaders start fetching when navigation starts, in parallel for every matched route, avoiding render-then-fetch waterfalls.
- Split code by route with
lazy, and treat client-side redirects as a convenience: the server still has to authorise every request.
Check yourself
Quiz
0/4 correct · 4 left
1.A user reloads https://app.example.com/projects/42 on a static host and gets a 404. What is the fix?
2.Where should the current page number and filter of a list live?
3.Why do route loaders reduce request waterfalls compared to fetching in useEffect?
4.A layout route's loader redirects signed-out users to /login. What does that protect?
Finished reading?
Mark it complete to fill in your roadmap.