Client-Side Routing with React Router

Understand how client-side routing works in a single-page React app, and learn to define nested routes, read route and query parameters, navigate programmatically, and protect, lazy-load, and error-handle routes using the react-router-dom ecosystem library.

React itself has no built-in concept of a 'page' or a URL — it just renders components. Routing is the layer that maps a URL to a tree of components, keeps the browser's address bar and history in sync with what's on screen, and lets links and back/forward buttons work the way users expect, all without a full page reload. The dominant tool for this in the React ecosystem is react-router-dom, and its ideas (route trees, nested layouts, loaders) are worth understanding deeply because most production React apps depend on them.

Why single-page apps need a routing library

In a traditional multi-page site, every URL corresponds to a separate HTML document that the browser requests fresh from the server. In a single-page application (SPA), there is only one HTML document — the JavaScript bundle intercepts navigation, updates the URL using the browser's History API (pushState/replaceState), and swaps out which components are rendered, all without asking the server for a new page. This is faster (no full reload, shared state persists) but it means the app itself is now responsible for everything the browser used to handle: matching a URL to content, updating the address bar, supporting the back button, and making sure a shared link actually loads the right view.

React does not ship this machinery itself, by design — the core library only concerns itself with rendering components from state and props, not with URLs. That gap is filled by react-router-dom, which is not part of React but is the de-facto standard routing library for React apps (Next.js and a few frameworks bundle their own file-based routers, but react-router-dom is the one you'll encounter in the large majority of plain React + Vite/CRA projects, and its concepts — route trees, params, loaders, nested layouts — reappear in almost every alternative).

Without a router, you'd have to hand-roll URL parsing, listen for popstate events, and manually decide which components to render based on window.location.pathname — all solvable, but exactly the kind of repetitive, easy-to-get-subtly-wrong plumbing a library exists to standardize.

Defining routes with createBrowserRouter and nested routes

The modern react-router-dom API centers on createBrowserRouter, which takes an array (or JSX tree) of route objects describing which component renders for which URL path, and returns a router object you pass to a <RouterProvider>. Each route object has a path and an element (or, in the object form, a Component), and routes can nest inside each other via a children array — a child route renders inside its parent's <Outlet /> placeholder, letting a parent route own a shared layout (navigation, sidebar, footer) while only the inner content changes as the child route changes.

Nesting mirrors how real apps are structured: a dashboard layout with a persistent sidebar might have several child routes (/dashboard/overview, /dashboard/billing) that all render inside that same sidebar shell. Without nested routing you'd have to duplicate the sidebar in every top-level page component; with it, the layout is defined once, in one place, and the router handles swapping only the inner content.

Consider this example route tree for a typical app: / ├── dashboard ├── products │ ├── list │ └── :id ├── settings └── login Here, /products/list and /products/:id are both children of /products, meaning they likely share a products layout (perhaps a filter sidebar or breadcrumb) rendered via an <Outlet />, while /dashboard, /settings, and /login are top-level siblings with no shared inner layout beyond whatever the root route itself provides.

Route parameters, query parameters, links, and navigation

A path segment like :id in /products/:id is a route parameter — a dynamic piece of the URL captured for that render. Inside the matched component, the useParams hook returns an object of all such captured segments as strings (e.g. { id: '42' }), which you then use to fetch or look up the right data. Route params are for identifying a specific resource as part of the path structure itself (which product, which user).

Query parameters (also called search parameters) are the ?key=value pairs after the path, used for things that modify a view without being part of its identity — a search term, a sort order, a page number, a set of active filters. The useSearchParams hook returns a URLSearchParams-like object plus a setter function, so you can read and update query params reactively; because they live in the URL, they survive a page refresh and can be bookmarked or shared, which is exactly why they're preferred over local component state for things like 'what page of results am I on.'

For navigation, the <Link to="/products"> component renders an actual anchor tag but intercepts the click to perform client-side navigation instead of a full page load — always prefer it (or <NavLink>, which adds active-link styling) over a raw <a> tag for in-app links. For navigation triggered by code rather than a click — after a form submits successfully, after a login completes, on a timer — the useNavigate hook returns a navigate function you call imperatively: navigate('/dashboard') or navigate(-1) to go back.

Protected routes, not-found routes, error routes, and lazy routes

A protected (or authenticated) route only allows access to users who meet some condition, typically being logged in. React Router has no built-in 'protected route' primitive; the standard pattern is a wrapper component that checks auth state and either renders its children/<Outlet /> or redirects (via <Navigate to="/login" />) to a login page — wrapping a whole group of nested routes in one such guard element protects all of them at once.

A not-found route catches any URL that doesn't match a defined path — in the array-based API this is a route with path: "*" placed last, rendering a 404-style component. An error route (specified per-route via errorElement, or per-tree via a root errorElement) catches errors thrown while rendering, loading data for, or matching that route, showing a fallback UI instead of a blank crashed page — this is the router's own error-boundary mechanism, layered on top of (and complementary to) React error boundaries in general.

A lazy route defers loading a route's component code until that route is actually visited, using React.lazy (or the router's own lazy route-object field) combined with code-splitting from your bundler — this keeps the initial JavaScript bundle small by not shipping code for /settings or /products/:id until a user actually navigates there. Related to this is the idea of route data loading (loaders): a route can declare a loader function that fetches the data that route needs before rendering, so the component renders with data already available rather than rendering first and fetching after — this pattern (native to react-router-dom's data APIs) shifts 'when do we fetch' from inside the component to the routing layer itself.

Defining a route tree with createBrowserRouter

RootLayout renders <Outlet /> where its matched child route's element goes — this is how a shared nav bar persists across every page. The catch-all path: "*" must be the last child so it only matches when nothing earlier in the array did.

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

const router = createBrowserRouter([
  {
    path: "/",
    element: <RootLayout />, // renders shared nav + <Outlet />
    errorElement: <RootErrorPage />,
    children: [
      { path: "dashboard", element: <DashboardPage /> },
      {
        path: "products",
        element: <ProductsLayout />, // shared products shell + <Outlet />
        children: [
          { path: "list", element: <ProductList /> },
          { path: ":id", element: <ProductDetail /> },
        ],
      },
      { path: "settings", element: <SettingsPage /> },
      { path: "login", element: <LoginPage /> },
      { path: "*", element: <NotFoundPage /> },
    ],
  },
]);

function App() {
  return <RouterProvider router={router} />;
}

Code Examples

useParams reads the :id captured from the path (/products/42 -> id: '42'), while useSearchParams reads and writes the ?tab=... query string independently of the path — clicking the button updates the URL to /products/42?tab=reviews without a full navigation. Expected result: Visiting /products/42?tab=reviews renders 'Product 42' with 'Current tab: reviews'.

const { id } = useParams<{ id: string }>();

ProtectedRoute is placed as the parent of any routes that require auth; if the user isn't authenticated it redirects to /login instead of rendering the Outlet. After a successful login, useNavigate moves the user to /dashboard imperatively, and { replace: true } swaps the login page out of history so the back button doesn't return to it.

Common mistake: Guarding routes by conditionally rendering nothing (returning null) instead of navigating to /login — this leaves the user on the original protected URL with a blank screen instead of redirecting them somewhere useful.

import { useParams, useSearchParams } from "react-router-dom";

function ProductDetail() {
  const { id } = useParams<{ id: string }>();
  const [searchParams, setSearchParams] = useSearchParams();
  const tab = searchParams.get("tab") ?? "overview";

  return (
    <div>
      <h1>Product {id}</h1>
      <button onClick={() => setSearchParams({ tab: "reviews" })}>
        Show reviews
      </button>
      <p>Current tab: {tab}</p>
    </div>
  );
}
import { Navigate, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "./useAuth";

function ProtectedRoute() {
  const { isAuthenticated } = useAuth();
  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }
  return <Outlet />; // renders whichever protected child route matched
}

function LoginPage() {
  const navigate = useNavigate();
  const { login } = useAuth();

  async function handleSubmit(email: string, password: string) {
    await login(email, password);
    navigate("/dashboard", { replace: true });
  }

  return <LoginForm onSubmit={handleSubmit} />;
}

Example route tree

Nested paths mirror nested layouts: /products/list and /products/:id both render inside ProductsLayout's <Outlet />, which itself renders inside RootLayout's <Outlet />.

/
├── dashboard
├── products
│   ├── list
│   └── :id
├── settings
└── login

RootLayout (nav + <Outlet/>)
  └─ ProductsLayout (products shell + <Outlet/>)
       ├─ list  -> ProductList
       └─ :id   -> ProductDetail

Important Rules

Use <Link>/<NavLink> for in-app navigation triggered by a click, and useNavigate only for navigation triggered by code (after a submit, a timer, an effect).

Reserve route params (:id) for identifying a specific resource in the URL path, and query params (?sort=...) for view state that modifies but doesn't identify the resource.

Put a catch-all path: "*" route last in every route array so unmatched URLs get a real not-found page instead of a blank screen.

Group protected routes under one guard element rather than repeating an auth check inside every individual page component.

Lazy-load route components for pages that aren't needed on first load, so the initial bundle only contains what the landing route requires.

Where this shows up

An e-commerce app uses route params for /products/:id to load the specific product's detail page, query params for /products?category=shoes&sort=price so filters and sort order are shareable and survive a refresh, a protected-route wrapper around /checkout and /account so an unauthenticated user is redirected to /login, a catch-all not-found route for mistyped or removed product URLs, and lazy-loaded routes for rarely visited pages like /returns-policy so they don't bloat the bundle every visitor downloads on their first page load.

<a href="/products">Products</a> <Link to="/products">Products</Link>

A plain <a href> triggers a full browser navigation, reloading the entire app and discarding all in-memory state, whereas <Link> intercepts the click and lets the router update the view without a reload.

[multiple-choice] What is react-router-dom's relationship to React? A. It is a built-in part of the core React library B. It is a separate, ecosystem-standard library that adds routing on top of React C. It replaces React's rendering engine entirely D. It is only usable with class components

B. It is a separate, ecosystem-standard library that adds routing on top of React — React itself has no routing concept. react-router-dom is a widely-adopted third-party library that provides routing on top of plain React, and it's the de-facto standard most non-framework React apps use.

[multiple-choice] For the route /products/:id, which hook reads the captured id value inside the matched component? A. useSearchParams B. useLocation C. useParams D. useNavigate

C. useParams — useParams returns an object of the dynamic path segments captured by the matched route, such as { id: '42' } for /products/42.

[true-false] A catch-all path: "*" route should be placed first in a route's children array so it's checked before more specific routes. A. True B. False

B. False — It must be placed last. Routers match in order, and a catch-all placed first would swallow every URL before more specific routes ever got a chance to match.

Add a new nested route /settings/profile and /settings/security under a shared SettingsLayout (with its own <Outlet />), and make the whole /settings subtree require authentication using the ProtectedRoute pattern shown above.

{ element: <ProtectedRoute />, children: [ { path: "settings", element: <SettingsLayout />, children: [ { path: "profile", element: <ProfilePage /> }, { path: "security", element: <SecurityPage /> }, ], }, ], }

Wrap the settings route object's children in one more level: a ProtectedRoute parent whose single child is the SettingsLayout route, which in turn has profile and security as its own children.