# Routing

> Turn a single React app into many pages: URLs, nested layouts, params, search params, loaders and code splitting.

Frontend Developer roadmap · TypeScript & React · 17 min read · https://levelcode.org/roadmaps/frontend/client-side-routing

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:

1. Cancels the browser's default navigation.
2. Calls `history.pushState()` to change the URL without a request.
3. 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:

```bash
npm install react-router
```

Since 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`:

```tsx
// 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: true` marks the child that renders at the parent's exact path (`/`).
- `:projectId` is 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:

```tsx
// 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.

> [!TIP]
> Always navigate with `<Link to="...">`, never `<a href="...">`. A plain anchor triggers a full page load, throwing away all client state and re-downloading the app. `Link` renders a real `<a>` (so middle-click and "open in new tab" still work) but intercepts normal clicks.

## Reading the URL: params and search params

Path params identify **which** resource. Read them with `useParams`:

```tsx
// 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.

```tsx
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`:

```tsx
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](https://levelcode.org/roadmaps/frontend/data-fetching) 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:

```tsx
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>
}
```

```tsx
{
  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.

```sequence
title: Navigating to /projects/42
participants: User, Router, Loader, API, Page
User -> Router: Click <Link to="/projects/42">
Router -> Loader: Run projectLoader({ projectId: "42" })
> The fetch starts before any new component renders.
Loader -> API: GET /api/projects/42
API --> Loader: 200 { name: "Website" }
Loader --> Router: Project data
Router -> Page: Render ProjectPage
> useLoaderData() returns the project immediately.
Router -> User: URL updated via pushState
```

## 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`:

```tsx
{
  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:

```tsx
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:

1. A root layout with navigation (Home, Projects) that highlights the active link.
2. `/projects` lists projects from a hard-coded array, filterable by `?status=open|archived|all` stored in the URL.
3. `/projects/:projectId` uses a loader that throws a 404 `Response` for unknown IDs, with an `ErrorBoundary` that shows "Project not found".
4. A `/reports` route that's lazy-loaded. Confirm in the Network tab that its chunk only downloads when you visit it.

**Solution**

```tsx
// 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} />)
```

```tsx
// 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 `pushState` and renders the matching components without a page reload. Configure your host to serve `index.html` for unknown paths.
- Layout routes render shared UI once and put the matched child in `<Outlet />`. Navigate with `Link` or `NavLink`, 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.

## Quiz

1. A user reloads https://app.example.com/projects/42 on a static host and gets a 404. What is the fix?

   A. Use HashRouter so the server never sees the path
   B. Configure the host to serve index.html for unknown paths
   C. Add a <Route path="*"> to the router
   D. Replace Link with <a href> so the browser knows the route

   **Answer: B.** On reload the browser requests /projects/42 from the server, before any JavaScript runs. The host must fall back to index.html so the router can take over. A catch-all route only helps once the app has loaded. Hash routing works but gives ugly URLs and is a workaround, not the fix.

2. Where should the current page number and filter of a list live?

   A. In useState in the list component
   B. In localStorage
   C. In the URL search params
   D. In a React context

   **Answer: C.** Search params survive reloads, can be shared as links and work with back and forward. Component state is lost on reload, and localStorage or context can't be shared or bookmarked.

3. Why do route loaders reduce request waterfalls compared to fetching in useEffect?

   A. Loaders cache responses forever
   B. Loaders run on the server
   C. Loaders for all matched routes start as soon as navigation begins, before components render
   D. Loaders batch every request into one HTTP call

   **Answer: C.** With effects, a parent must render before its child mounts and starts its own fetch. The router knows every matched route from the URL alone, so it can start all their loaders in parallel immediately.

4. A layout route's loader redirects signed-out users to /login. What does that protect?

   A. Nothing on the server: the API must still check authorisation on every request
   B. All data, because the API can't be called without the page
   C. Only pages that use lazy()
   D. The JavaScript bundle, which can no longer be downloaded

   **Answer: A.** Client-side code can be changed or bypassed entirely by calling the API directly. Route guards improve the experience; authorisation belongs on the server.
