Skip to content
elephantoo

Routing with React Router

Lesson 18 of 30 20 min read

Pages, links, nested layouts, URL params, search params, loaders and 404s.


So far each app has been a single screen. Real apps have many pages — a home page, a course list, a lesson page, settings — each with its own URL that users can bookmark and share. React Router is the most popular library for this. It maps URLs to components, handles links and the back button, and can even load data before a page renders.

This lesson uses React Router v8. If you read older tutorials: the react-router-dom package is gone — import from react-router (and RouterProvider from react-router/dom).

Installing#

Terminal
npm install react-router

Your first router#

Create a router once, outside your components, and render it with RouterProvider:

src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter } from "react-router";
import { RouterProvider } from "react-router/dom";
import Home from "./pages/Home";
import About from "./pages/About";

const router = createBrowserRouter([
  { path: "/", Component: Home },
  { path: "/about", Component: About },
]);

createRoot(document.getElementById("root")).render(
  <StrictMode>
    <RouterProvider router={router} />
  </StrictMode>
);

Visit / and you see Home; visit /about and you see About. (You can also write element: <Home /> instead of Component: Home.)

Use Link for in-app links. It updates the URL without reloading the page:

JSX
import { Link, NavLink } from "react-router";

function Nav() {
  return (
    <nav>
      <Link to="/">Elephantoo</Link>
      <NavLink to="/courses" className={({ isActive }) => (isActive ? "active" : undefined)}>
        Courses
      </NavLink>
      <NavLink to="/about">About</NavLink>
    </nav>
  );
}

NavLink knows whether it matches the current URL. It adds an active class by default (and aria-current="page" for screen readers), or you can pass a function to className/style.

Layouts with nested routes and <Outlet />#

Most pages share a header and footer. Put them in a layout route and nest pages inside it:

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

function RootLayout() {
  return (
    <>
      <header>
        <Nav />
      </header>
      <main>
        <Outlet /> {/* the matching child route renders here */}
      </main>
      <footer>© Elephantoo</footer>
    </>
  );
}

const router = createBrowserRouter([
  {
    path: "/",
    Component: RootLayout,
    children: [
      { index: true, Component: Home }, // "/"
      { path: "about", Component: About }, // "/about"
      {
        path: "courses",
        children: [
          { index: true, Component: CourseList }, // "/courses"
          { path: ":courseId", Component: CourseDetail }, // "/courses/react"
          { path: ":courseId/:lessonSlug", Component: LessonPage }, // "/courses/react/jsx"
        ],
      },
      { path: "*", Component: NotFound }, // anything else
    ],
  },
]);
  • index: true marks the default child shown at the parent's exact URL.
  • Child paths are relative — about under / becomes /about.
  • * matches anything not matched elsewhere, perfect for a 404 page.

URL parameters with useParams#

A segment starting with : is a dynamic parameter:

JSX
import { Link, useParams } from "react-router";

const lessons = {
  react: [
    { slug: "jsx", title: "JSX" },
    { slug: "components-and-props", title: "Components & props" },
  ],
};

function CourseDetail() {
  const { courseId } = useParams(); // "/courses/react" → { courseId: "react" }
  const list = lessons[courseId];

  if (!list) return <p>Course “{courseId}” not found.</p>;

  return (
    <>
      <h1>{courseId} course</h1>
      <ol>
        {list.map((l) => (
          <li key={l.slug}>
            <Link to={l.slug}>{l.title}</Link> {/* relative: /courses/react/jsx */}
          </li>
        ))}
      </ol>
    </>
  );
}

Params are always strings — convert with Number(id) if you need a number.

Search params with useSearchParams#

Query strings (/courses?level=beginner&q=hooks) are great for filters, because the state lives in the URL and survives refreshes and sharing:

JSX
import { useSearchParams } from "react-router";

function CourseList() {
  const [searchParams, setSearchParams] = useSearchParams();
  const level = searchParams.get("level") ?? "all";
  const q = searchParams.get("q") ?? "";

  function update(key, value) {
    setSearchParams((prev) => {
      const next = new URLSearchParams(prev);
      if (value) next.set(key, value);
      else next.delete(key);
      return next;
    });
  }

  const visible = courses.filter(
    (c) => (level === "all" || c.level === level) && c.title.toLowerCase().includes(q.toLowerCase())
  );

  return (
    <>
      <input value={q} onChange={(e) => update("q", e.target.value)} placeholder="Search" />
      <select value={level} onChange={(e) => update("level", e.target.value === "all" ? "" : e.target.value)}>
        <option value="all">All levels</option>
        <option value="beginner">Beginner</option>
        <option value="advanced">Advanced</option>
      </select>
      <ul>{visible.map((c) => <li key={c.id}>{c.title}</li>)}</ul>
    </>
  );
}

After a form submits or a login succeeds, redirect with useNavigate:

JSX
import { useNavigate } from "react-router";

function LoginForm() {
  const navigate = useNavigate();

  async function handleSubmit(e) {
    e.preventDefault();
    await logIn(new FormData(e.currentTarget));
    navigate("/dashboard", { replace: true }); // replace: the back button won't return to /login
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" required />
      <button>Log in</button>
      <button type="button" onClick={() => navigate(-1)}>
        Back
      </button>
    </form>
  );
}

Loading data with loaders#

React Router can fetch data before rendering a route. Add a loader and read its result with useLoaderData. No effects, no loading flags, no race conditions:

JSX
import { useLoaderData, Link } from "react-router";

async function postLoader({ params }) {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.postId}`);
  if (!res.ok) throw new Response("Post not found", { status: 404 });
  return res.json();
}

function PostPage() {
  const post = useLoaderData();
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      <Link to={`/posts/${post.id + 1}`}>Next post →</Link>
    </article>
  );
}

const router = createBrowserRouter([
  { path: "/posts/:postId", loader: postLoader, Component: PostPage, ErrorBoundary: RouteError },
]);

While the next route's loader runs, the current page stays on screen. Show a global pending indicator with useNavigation:

JSX
import { Outlet, useNavigation } from "react-router";

function RootLayout() {
  const navigation = useNavigation();
  return (
    <>
      {navigation.state === "loading" && <div className="progress-bar" aria-label="Loading" />}
      <Outlet />
    </>
  );
}

Mutations with actions and <Form>#

An action handles form submissions. React Router's <Form> sends the form data to the route's action, then re-runs the loaders so the page shows fresh data:

JSX
import { Form, redirect, useActionData, useNavigation } from "react-router";

async function newPostAction({ request }) {
  const formData = await request.formData();
  const title = String(formData.get("title") ?? "").trim();
  if (title.length < 3) return { error: "Title must be at least 3 characters" };

  const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title, body: formData.get("body"), userId: 1 }),
  });
  const post = await res.json();
  return redirect(`/posts/${post.id}`);
}

function NewPost() {
  const actionData = useActionData();
  const navigation = useNavigation();
  const submitting = navigation.state === "submitting";

  return (
    <Form method="post">
      <input name="title" placeholder="Title" />
      <textarea name="body" />
      {actionData?.error && <p role="alert">{actionData.error}</p>}
      <button disabled={submitting}>{submitting ? "Publishing…" : "Publish"}</button>
    </Form>
  );
}

// { path: "/posts/new", action: newPostAction, Component: NewPost }

Error pages#

When a loader throws or a component crashes, React Router renders the nearest route's ErrorBoundary:

JSX
import { isRouteErrorResponse, Link, useRouteError } from "react-router";

function RouteError() {
  const error = useRouteError();

  if (isRouteErrorResponse(error)) {
    return (
      <>
        <h1>
          {error.status} {error.statusText}
        </h1>
        <p>{error.data}</p>
        <Link to="/">Go home</Link>
      </>
    );
  }
  return <h1>Something went wrong: {error instanceof Error ? error.message : "Unknown error"}</h1>;
}

Put one on the root route so every page has a fallback.

Protecting routes#

Redirect logged-out users from a loader — it runs before the page renders, so private content never flashes:

JSX
import { redirect } from "react-router";

async function requireUser() {
  const user = await getCurrentUser();
  if (!user) throw redirect("/login");
  return user;
}

const routes = [
  {
    path: "/dashboard",
    loader: async () => ({ user: await requireUser() }),
    Component: Dashboard,
  },
];

Tip: client-side checks improve UX, but real security lives on your server. Always verify permissions in your API.

Deploying an SPA with routing#

Client-side routes like /courses/react don't exist as files on the server. If a user refreshes on that URL, the server must still send index.html. You'll configure this "SPA fallback" rewrite in the Deployment lesson.

Common mistakes#

  • Importing from react-router-dom (removed in v8) — use react-router and react-router/dom.
  • Using <a href> for internal links, which reloads the whole app.
  • Forgetting <Outlet /> in a layout, so child routes never appear.
  • Creating the router inside a component (it should be created once, at module level).
  • Treating useParams() values as numbers.
  • Forgetting the server rewrite, causing 404s on refresh in production.

What's next#

As apps grow, some components re-render more than they need to. Next: memoisation with useMemo, useCallback and memo.

Check your understanding

Quick quiz

0/3 answered
  1. 1.In React Router v8, where do you import RouterProvider from?

  2. 2.What does <Outlet /> do in a parent route's component?

  3. 3.Why use <Link to="/about"> instead of <a href="/about"> for in-app navigation?

Finished reading?

Mark this lesson complete to track your progress.