Skip to main content
React · lesson 13 of 22
React intermediate Lesson 13 of 22

Routing with React Router

Build a nested route tree with React Router 8's createMemoryRouter and RouterProvider, click a real Link in Vitest, and watch an unmatched URL render React Router's own 404 instead of a blank screen.

By SkillByExample · Updated

react react-router routing vitest

React Router matches a URL against a tree of routes and swaps whichever component is mounted, without the browser ever requesting a new document. This lesson uses its data-mode API — createMemoryRouter and RouterProvider — because it’s the one that also knows how to render an error page for a URL that matches nothing at all; a plain <Routes>/<Route> tree, shown at the end for contrast, needs a catch-all route for the same job. (A server framework like Next.js builds its own routing on top of the filesystem and runs on the server — see The App Router and the Route Table for that version; this lesson’s router is the client-side library underneath it.)

Project Setup

The same Vitest project from State with useState, with react-router added:

{
  "name": "react-app",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "dependencies": {
    "react": "19.3.1",
    "react-dom": "19.3.1",
    "react-router": "8.5.1"
  },
  "devDependencies": {
    "vitest": "5.0.5",
    "@testing-library/react": "16.3.3",
    "@testing-library/user-event": "14.6.8",
    "@testing-library/jest-dom": "7.0.1",
    "@vitejs/plugin-react": "6.1.2",
    "jsdom": "30.1.3"
  }
}
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    setupFiles: ["./vitest.setup.js"],
  },
});
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";

afterEach(() => {
  cleanup();
});

Every command below runs inside Docker’s node:24.18.1 image, from /root/react-app, with NO_COLOR=1 so the output below is plain text instead of ANSI colour codes, and grep strips Vitest’s own Start at/Duration lines, since their numbers change on every run:

$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-router-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npm install --no-audit --no-fund

added 101 packages in 21s

A Router with Nested Routes and an Outlet

Layout renders the nav once and an <Outlet /> — a placeholder for whichever child route matched. Home is the index route (matches / exactly); UserProfile reads the :id param from the URL and looks it up in a plain object, no loader involved:

// src/routing/App.tsx
import { Outlet, Link, useParams, useRouteError, isRouteErrorResponse } from "react-router";

export const users: Record<string, { name: string }> = {
  "1": { name: "Ada" },
  "2": { name: "Grace" },
};

export function Layout() {
  return (
    <div>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/users/2">Grace</Link>
      </nav>
      <Outlet />
    </div>
  );
}

export function Home() {
  return <p>Pick a user from the nav.</p>;
}

export function UserProfile() {
  const { id } = useParams();
  const user = id ? users[id] : undefined;
  if (!user) {
    return <p>No such user</p>;
  }
  return <p>User {id}: {user.name}</p>;
}

export function ErrorPage() {
  const error = useRouteError();
  if (isRouteErrorResponse(error)) {
    return <p>{error.status} {String(error.data)}</p>;
  }
  return <p>Unknown error</p>;
}

createMemoryRouter builds the router from a route array — no real URL bar, which is exactly what a test needs. Clicking the Grace link should move router.state.location.pathname from / to /users/2 and swap Outlet’s content:

// src/routing/LinkNavigation.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter, RouterProvider } from "react-router";
import { Layout, Home, UserProfile } from "./App.js";

const routes = [
  {
    path: "/",
    element: <Layout />,
    children: [
      { index: true, element: <Home /> },
      { path: "users/:id", element: <UserProfile /> },
    ],
  },
];

describe("clicking a Link", () => {
  it("navigates to the matched user without a page reload", async () => {
    const user = userEvent.setup();
    const router = createMemoryRouter(routes, { initialEntries: ["/"] });
    render(<RouterProvider router={router} />);

    expect(screen.getByText("Pick a user from the nav.")).toBeInTheDocument();

    await user.click(screen.getByRole("link", { name: "Grace" }));

    console.log(`${screen.getByRole("paragraph").textContent} at ${router.state.location.pathname}`);
    expect(screen.getByText("User 2: Grace")).toBeInTheDocument();
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-router-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/routing/LinkNavigation.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/routing/LinkNavigation.test.tsx > clicking a Link > navigates to the matched user without a page reload
User 2: Grace at /users/2

 ✓ src/routing/LinkNavigation.test.tsx > clicking a Link > navigates to the matched user without a page reload 295ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

The logged line proves it: User 2: Grace at /users/2 — the DOM and router.state.location agree, and the click never touched a real network request. index: true is what made Home the one shown at / itself, rather than requiring an explicit empty path.

An Unmatched URL Is a Real 404

App.tsx above already exports ErrorPage, built with useRouteError and isRouteErrorResponse — React Router’s own hooks for reading what went wrong. Attach it to the root route as errorElement, then point the router at a URL nothing matches:

// src/routing/NotFound.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { createMemoryRouter, RouterProvider } from "react-router";
import { Layout, Home, UserProfile, ErrorPage } from "./App.js";

const routes = [
  {
    path: "/",
    element: <Layout />,
    errorElement: <ErrorPage />,
    children: [
      { index: true, element: <Home /> },
      { path: "users/:id", element: <UserProfile /> },
    ],
  },
];

describe("an unmatched URL", () => {
  it("renders the root errorElement instead of the layout", () => {
    const router = createMemoryRouter(routes, { initialEntries: ["/nope"] });
    render(<RouterProvider router={router} />);

    console.log(screen.getByRole("paragraph").textContent);
    expect(screen.getByText('404 Error: No route matches URL "/nope"')).toBeInTheDocument();
    expect(screen.queryByRole("link", { name: "Home" })).not.toBeInTheDocument();
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-router-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/routing/NotFound.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/routing/NotFound.test.tsx > an unmatched URL > renders the root errorElement instead of the layout
404 Error: No route matches URL "/nope"

 ✓ src/routing/NotFound.test.tsx > an unmatched URL > renders the root errorElement instead of the layout 187ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

404 Error: No route matches URL "/nope" is React Router’s own message, surfaced through error.data on the ErrorResponse object useRouteError returns — nothing here wrote that string. The Home link is gone too: an unmatched URL has no matched route at all, so the router renders the root route’s errorElement in place of its element, Layout included, not just the missing child inside Outlet.

The Declarative API, for Contrast

React Router 8 still exports the older <Routes>/<Route> components, matched as React renders rather than resolved up front from one route array. There’s no errorElement here — nothing throws for an unmatched path, so nothing matches and nothing renders unless a route is there to catch it:

// src/routing/Declarative.tsx
import { Routes, Route, Link } from "react-router";

function Home() {
  return <p>Welcome home.</p>;
}

function About() {
  return <p>About this site.</p>;
}

export function DeclarativeApp() {
  return (
    <div>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/about">About</Link>
      </nav>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </div>
  );
}
// src/routing/Declarative.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { DeclarativeApp } from "./Declarative.js";

describe("declarative Routes", () => {
  it("clicking About navigates without createMemoryRouter", async () => {
    const user = userEvent.setup();
    render(
      <MemoryRouter initialEntries={["/"]}>
        <DeclarativeApp />
      </MemoryRouter>
    );

    expect(screen.getByText("Welcome home.")).toBeInTheDocument();

    await user.click(screen.getByRole("link", { name: "About" }));

    expect(screen.getByText("About this site.")).toBeInTheDocument();
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-router-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/routing/Declarative.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

 ✓ src/routing/Declarative.test.tsx > declarative Routes > clicking About navigates without createMemoryRouter 259ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

MemoryRouter wraps the component tree instead of being handed a route array up front — <Route> elements are only read once React actually renders <Routes>, which is also why there’s no equivalent of errorElement here: there’s no central router object for React Router to attach one to. Route Loaders and Error Elements goes further into what createMemoryRouter’s route objects can do that this API can’t.

Practice

1. Add a Link and route for Ada (id: "1", already in users) alongside Grace’s, and confirm clicking it reaches her profile.

Solution
// src/routing/practice/AppWithAdaLink.tsx
import { Outlet, Link, useParams } from "react-router";
import { users } from "../App.js";

export function Layout() {
  return (
    <div>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/users/1">Ada</Link>
        <Link to="/users/2">Grace</Link>
      </nav>
      <Outlet />
    </div>
  );
}

export function Home() {
  return <p>Pick a user from the nav.</p>;
}

export function UserProfile() {
  const { id } = useParams();
  const user = id ? users[id] : undefined;
  if (!user) {
    return <p>No such user</p>;
  }
  return <p>User {id}: {user.name}</p>;
}
// src/routing/practice/AdaLink.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter, RouterProvider } from "react-router";
import { Layout, Home, UserProfile } from "./AppWithAdaLink.js";

const routes = [
  {
    path: "/",
    element: <Layout />,
    children: [
      { index: true, element: <Home /> },
      { path: "users/:id", element: <UserProfile /> },
    ],
  },
];

describe("Ada link", () => {
  it("clicking Ada navigates to her profile", async () => {
    const user = userEvent.setup();
    const router = createMemoryRouter(routes, { initialEntries: ["/"] });
    render(<RouterProvider router={router} />);

    await user.click(screen.getByRole("link", { name: "Ada" }));

    console.log(`${screen.getByRole("paragraph").textContent} at ${router.state.location.pathname}`);
    expect(screen.getByText("User 1: Ada")).toBeInTheDocument();
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-router-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/routing/practice/AdaLink.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/routing/practice/AdaLink.test.tsx > Ada link > clicking Ada navigates to her profile
User 1: Ada at /users/1

 ✓ src/routing/practice/AdaLink.test.tsx > Ada link > clicking Ada navigates to her profile 279ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

User 1: Ada at /users/1 — the same UserProfile component, the same users lookup from App.js, just reached through a different Link.

2. Visit /users/99 — a URL that matches users/:id but has no record in users — and confirm it’s different from an unmatched URL: the layout and nav still render, since the route itself matched fine.

Solution
// src/routing/practice/NoSuchUser.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { createMemoryRouter, RouterProvider } from "react-router";
import { Layout, Home, UserProfile, ErrorPage } from "../App.js";

const routes = [
  {
    path: "/",
    element: <Layout />,
    errorElement: <ErrorPage />,
    children: [
      { index: true, element: <Home /> },
      { path: "users/:id", element: <UserProfile /> },
    ],
  },
];

describe("a matched route with no matching record", () => {
  it("renders inside the layout, unlike an unmatched URL", () => {
    const router = createMemoryRouter(routes, { initialEntries: ["/users/99"] });
    render(<RouterProvider router={router} />);

    console.log(screen.getByRole("paragraph").textContent);
    expect(screen.getByText("No such user")).toBeInTheDocument();
    expect(screen.getByRole("link", { name: "Home" })).toBeInTheDocument();
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-router-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/routing/practice/NoSuchUser.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/routing/practice/NoSuchUser.test.tsx > a matched route with no matching record > renders inside the layout, unlike an unmatched URL
No such user

 ✓ src/routing/practice/NoSuchUser.test.tsx > a matched route with no matching record > renders inside the layout, unlike an unmatched URL 288ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

No such user comes from UserProfile’s own if (!user) branch, inside the normally-rendered Layout — the Home link proves the nav is still there. That’s the opposite of the previous section’s /nope, where nothing matched at all and errorElement replaced the layout entirely.

3. Add a <Link to="/nope"> inside the nav itself, and confirm clicking it from inside the app triggers the same errorElement as loading /nope directly.

Solution
// src/routing/practice/AppWithBrokenLink.tsx
import { Outlet, Link } from "react-router";

export function Layout() {
  return (
    <div>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/users/2">Grace</Link>
        <Link to="/nope">Broken</Link>
      </nav>
      <Outlet />
    </div>
  );
}
// src/routing/practice/BrokenLink.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter, RouterProvider } from "react-router";
import { Home, UserProfile, ErrorPage } from "../App.js";
import { Layout } from "./AppWithBrokenLink.js";

const routes = [
  {
    path: "/",
    element: <Layout />,
    errorElement: <ErrorPage />,
    children: [
      { index: true, element: <Home /> },
      { path: "users/:id", element: <UserProfile /> },
    ],
  },
];

describe("clicking a Link to an unmatched path", () => {
  it("triggers the same errorElement as typing the URL directly", async () => {
    const user = userEvent.setup();
    const router = createMemoryRouter(routes, { initialEntries: ["/"] });
    render(<RouterProvider router={router} />);

    await user.click(screen.getByRole("link", { name: "Broken" }));

    console.log(screen.getByRole("paragraph").textContent);
    expect(screen.getByText('404 Error: No route matches URL "/nope"')).toBeInTheDocument();
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-router-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/routing/practice/BrokenLink.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/routing/practice/BrokenLink.test.tsx > clicking a Link to an unmatched path > triggers the same errorElement as typing the URL directly
404 Error: No route matches URL "/nope"

 ✓ src/routing/practice/BrokenLink.test.tsx > clicking a Link to an unmatched path > triggers the same errorElement as typing the URL directly 445ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

Same message, same errorElement — a Link only changes what the browser (or jsdom, here) is asked to navigate to; the router doesn’t care whether that URL came from a click or initialEntries.

4. Add a trailing path="*" route to the declarative example from the previous section, and confirm it catches the same unmatched URL a <Routes> tree would otherwise render no <Route> for (React Router still logs the mismatch to the console).

Solution

First, the problem without a catch-all route — load /nope against the original DeclarativeApp, which has no path="*":

// src/routing/practice/NoCatchAllMatch.test.tsx
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { DeclarativeApp } from "../Declarative.js";

describe("an unmatched path with no catch-all route", () => {
  it("renders no Route element, though React Router still logs a warning", () => {
    const { container } = render(
      <MemoryRouter initialEntries={["/nope"]}>
        <DeclarativeApp />
      </MemoryRouter>
    );

    expect(container.querySelector("nav")).not.toBeNull();
    expect(container.querySelectorAll("p")).toHaveLength(0);
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-router-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/routing/practice/NoCatchAllMatch.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stderr | src/routing/practice/NoCatchAllMatch.test.tsx > an unmatched path with no catch-all route > renders no Route element, though React Router still logs a warning
No routes matched location "/nope" 

 ✓ src/routing/practice/NoCatchAllMatch.test.tsx > an unmatched path with no catch-all route > renders no Route element, though React Router still logs a warning 81ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

nav is still there, but there’s no <p> at all — neither of the two <Route>s matched /nope, so <Routes> rendered nothing in their place. React Router isn’t silent about it either: No routes matched location "/nope" is printed to stderr through console.warn, even though nothing about it reaches the DOM. Now add the catch-all route and confirm the same URL renders something instead:

// src/routing/practice/DeclarativeWithCatchAll.tsx
import { Routes, Route, Link } from "react-router";

function Home() {
  return <p>Welcome home.</p>;
}

function About() {
  return <p>About this site.</p>;
}

function NotFound() {
  return <p>404: page not found.</p>;
}

export function DeclarativeApp() {
  return (
    <div>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/nope">Broken</Link>
      </nav>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="*" element={<NotFound />} />
      </Routes>
    </div>
  );
}
// src/routing/practice/DeclarativeCatchAll.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { DeclarativeApp } from "./DeclarativeWithCatchAll.js";

describe("declarative catch-all route", () => {
  it("an unmatched path renders the trailing * route instead of throwing", async () => {
    const user = userEvent.setup();
    render(
      <MemoryRouter initialEntries={["/"]}>
        <DeclarativeApp />
      </MemoryRouter>
    );

    await user.click(screen.getByRole("link", { name: "Broken" }));

    console.log(screen.getByRole("paragraph").textContent);
    expect(screen.getByText("404: page not found.")).toBeInTheDocument();
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-router-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/routing/practice/DeclarativeCatchAll.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/routing/practice/DeclarativeCatchAll.test.tsx > declarative catch-all route > an unmatched path renders the trailing * route instead of throwing
404: page not found.

 ✓ src/routing/practice/DeclarativeCatchAll.test.tsx > declarative catch-all route > an unmatched path renders the trailing * route instead of throwing 296ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

path="*" matches anything the routes above it didn’t — there’s no errorElement involved, just an ordinary route whose path happens to match everything. Remove it and /nope is back to the first test’s result: the page renders no visible content for the unmatched path, but React Router still logs No routes matched location "/nope" to the console — declarative matching renders no <Route> rather than throwing, but it isn’t silent about it.

Frequently Asked Questions

What's the difference between createMemoryRouter + RouterProvider and the plain <Routes>/<Route> API?
Both match the same URL against the same shape of route tree, but createMemoryRouter builds the whole tree — nested children, an index route, and an errorElement — into one router object before anything renders, which is what lets it show an error page for an unmatched URL automatically. <Routes>/<Route> matches lazily as React renders each <Route>, has no per-route errorElement, and needs a trailing path="*" route to catch anything unmatched.
Why doesn't clicking a Link reload the page the way a plain <a href> would?
Link renders an <a> tag but intercepts the click with a handler that calls the router's own navigate function instead of letting the browser request a new document. React Router then only swaps whichever component is mounted at the matching Outlet, so the rest of the page never unmounts. Inside createMemoryRouter there's no real browser history or URL bar at all — router.state.location is the only place the current path lives.
Why put errorElement on the top-level route instead of on users/:id directly?
React Router walks up from wherever the error happened — or, for a URL that matches nothing at all, from the root — to the nearest ancestor route that has an errorElement, and renders that instead of the normal element tree, including the parent's own layout and its Outlet. Putting it on the root means one errorElement catches both an unmatched URL and anything thrown while rendering any child route; a child can still set its own errorElement to catch only errors from that branch.
Does createMemoryRouter replace createBrowserRouter in a real browser app?
No. Both build the same kind of router from the same route array; createMemoryRouter only swaps in a history implementation that lives in memory instead of a real browser URL bar, which is why it's the one to reach for in a test — there's no window or history to fake.
Does React Router 8 still support the plain BrowserRouter/Routes/Route components for a real app?
Yes — BrowserRouter and Routes/Route are still part of the package and render the same way the MemoryRouter example here does. They don't support loaders, actions, or errorElement; those only work through the router object that createMemoryRouter or createBrowserRouter returns.