React Router Examples (v7)

React Router 7 ships as one package, react-router. Wrap your app in BrowserRouter for simple routing, or build routes with createBrowserRouter and RouterProvider to use loaders, actions and error boundaries. Nest child routes under a layout that renders Outlet, read path segments with useParams and query strings with useSearchParams.

The APIs you use most
NeedUse
Set up routescreateBrowserRouter + RouterProvider
Shared layoutParent route + <Outlet />
/users/:iduseParams()
?sort=priceuseSearchParams()
Load dataloader + useLoaderData()
Unknown URLspath: "*"

Updated

Every example below is written for React Router 7 and imports from react-router. The small demo at the top is live, so you can click through nested routes and params; it runs on the router version this site uses, which is labelled on it.

Live demo: nested routes and params

An in-page router running react-router-dom 6.30 (the version this site ships, not v7). It has a /shop layout with a product list and /shop/:productId detail pages, a ?sort search param and a catch-all 404. The route code is the same in v7.

Setting up v7

The three ways to use React Router 7
ModeSetupGets you
Declarative<BrowserRouter> + <Routes>Matching, links, params. No loaders.
DatacreateBrowserRouter + <RouterProvider>Loaders, actions, Form, error boundaries, lazy, scroll restoration
Frameworkapp/routes.ts + @react-router/dev Vite pluginEverything in data mode plus file-based code splitting, type generation and server rendering

Install

In v7 one package covers web apps. react-router-dom still exists only as a re-export for older imports.

npm i react-router
# framework mode also needs the dev tools:
npm i -D @react-router/dev

Data mode: createBrowserRouter + RouterProvider

Routes are a plain array, which unlocks loaders, actions, pending UI and errorElement. Everything is imported from react-router.

import { createBrowserRouter, RouterProvider } from "react-router";
import { createRoot } from "react-dom/client";
import Root from "./Root";
import Home from "./Home";
import About from "./About";

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

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

Declarative mode: <BrowserRouter>

The smallest setup and the closest to v6 code. It has no loaders or actions; fetch data inside components instead.

import { BrowserRouter, Routes, Route } from "react-router";
import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")).render(
  <BrowserRouter>
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="about" element={<About />} />
    </Routes>
  </BrowserRouter>
);

Framework mode: app/routes.ts

The Vite plugin from @react-router/dev reads this file, splits code per route and generates types into ./+types/ for each route module.

// app/routes.ts
import { type RouteConfig, index, layout, prefix, route } from "@react-router/dev/routes";

export default [
  index("./routes/home.tsx"),
  route("about", "./routes/about.tsx"),
  layout("./routes/account-layout.tsx", [
    route("login", "./routes/login.tsx"),
    route("signup", "./routes/signup.tsx"),
  ]),
  ...prefix("projects", [
    index("./routes/projects.tsx"),
    route(":projectId", "./routes/project.tsx"),
  ]),
  route("*", "./routes/not-found.tsx"),
] satisfies RouteConfig;

Framework mode: a typed route module

Each route file exports its loader and a default component. The generated Route types give params and loaderData real types.

// app/routes/project.tsx
import type { Route } from "./+types/project";

export async function loader({ params }: Route.LoaderArgs) {
  const res = await fetch(`https://api.example.com/projects/${params.projectId}`);
  if (!res.ok) throw new Response("Not found", { status: 404 });
  return { project: await res.json() };
}

export default function Project({ loaderData }: Route.ComponentProps) {
  return <h1>{loaderData.project.name}</h1>;
}

Nested routes, params and search params

Nested routes and a layout with <Outlet>

The parent renders shared UI once; <Outlet /> marks where the matching child appears. A route with no path, like the one below, is a layout route.

import { Outlet, NavLink } from "react-router";

function DashboardLayout() {
  return (
    <div className="dashboard">
      <nav>
        <NavLink to="/dashboard" end>Overview</NavLink>
        <NavLink to="/dashboard/settings">Settings</NavLink>
      </nav>
      <main><Outlet /></main>
    </div>
  );
}

const routes = [
  {
    path: "/dashboard",
    Component: DashboardLayout,
    children: [
      { index: true, Component: Overview },          // /dashboard
      { path: "settings", Component: Settings },     // /dashboard/settings
    ],
  },
];

URL params with useParams

A segment starting with a colon becomes a param. Params are always strings, so convert numbers yourself.

// route: { path: "products/:productId", Component: Product }
import { useParams } from "react-router";

function Product() {
  const { productId } = useParams();
  return <h1>Product {productId}</h1>;
}

Search params with useSearchParams

Works like useState but stores the value in the query string, so filters survive a refresh and can be shared as a link.

import { useSearchParams } from "react-router";

function ProductList() {
  const [params, setParams] = useSearchParams();
  const sort = params.get("sort") ?? "newest";

  return (
    <select
      value={sort}
      onChange={(e) => setParams((prev) => {
        const next = new URLSearchParams(prev);
        next.set("sort", e.target.value);
        return next;
      })}
    >
      <option value="newest">Newest</option>
      <option value="price">Price</option>
    </select>
  );
}

Loaders, actions, auth, errors, lazy loading and scrolling

Loaders, actions, <Form> and redirect

The loader runs before the route renders. Submitting <Form method="post"> calls the action, and after it finishes the router reloads the loaders on the page. Return plain objects; json() and defer() are deprecated in v7.

import { Form, redirect, useLoaderData } from "react-router";

async function loader() {
  const res = await fetch("/api/notes");
  return { notes: await res.json() };
}

async function action({ request }) {
  const form = await request.formData();
  const res = await fetch("/api/notes", { method: "POST", body: form });
  const note = await res.json();
  return redirect(`/notes/${note.id}`);
}

function Notes() {
  const { notes } = useLoaderData();
  return (
    <>
      <ul>{notes.map((n) => <li key={n.id}>{n.title}</li>)}</ul>
      <Form method="post">
        <input name="title" required />
        <button type="submit">Add note</button>
      </Form>
    </>
  );
}

const routes = [{ path: "/notes", loader, action, Component: Notes }];

Protected routes with a redirect

Check the session in a parent loader and redirect before any child renders. Pass the original path so you can send people back after sign-in.

import { redirect, Outlet } from "react-router";

async function requireUser({ request }) {
  const user = await getCurrentUser(); // your own auth call
  if (!user) {
    const from = new URL(request.url).pathname;
    throw redirect(`/login?from=${encodeURIComponent(from)}`);
  }
  return { user };
}

const routes = [
  { path: "/login", Component: Login },
  {
    loader: requireUser,           // layout route: no path
    Component: () => <Outlet />,
    children: [
      { path: "/account", Component: Account },
      { path: "/billing", Component: Billing },
    ],
  },
];

Protected routes in declarative mode

Without loaders, a wrapper component checks auth and renders <Navigate> instead of the page.

import { Navigate, Outlet, useLocation } from "react-router";

function RequireAuth() {
  const { user } = useAuth(); // your own hook
  const location = useLocation();
  if (!user) return <Navigate to="/login" replace state={{ from: location }} />;
  return <Outlet />;
}

<Route element={<RequireAuth />}>
  <Route path="account" element={<Account />} />
</Route>

404 catch-all and error boundaries

A path of * catches any URL nothing else matched. errorElement (or ErrorBoundary as a component) renders when a loader, action or render throws; useRouteError tells you what was thrown.

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

function RouteError() {
  const error = useRouteError();
  if (isRouteErrorResponse(error)) {
    return <h1>{error.status} {error.statusText}</h1>;
  }
  return <h1>Something went wrong</h1>;
}

const routes = [
  {
    path: "/",
    Component: Root,
    ErrorBoundary: RouteError,   // or errorElement: <RouteError />
    children: [
      { index: true, Component: Home },
      { path: "*", Component: NotFound },
    ],
  },
];

Lazy routes

lazy loads a route's code the first time someone visits it. Return the route properties you want to fill in, such as Component and loader.

const routes = [
  {
    path: "/reports",
    lazy: async () => {
      const [{ default: Component }, { loader }] = await Promise.all([
        import("./routes/reports"),
        import("./routes/reports-loader"),
      ]);
      return { Component, loader };
    },
  },
];

Scroll restoration

Render <ScrollRestoration /> once in the root layout of a data router. It scrolls to the top on new pages and restores the old position on back and forward.

import { Outlet, ScrollRestoration } from "react-router";

function Root() {
  return (
    <>
      <Header />
      <Outlet />
      <ScrollRestoration />
    </>
  );
}

Migrating from v6

With every future flag turned on in v6, the move to v7 should need no other code changes. Ship each flag on its own so a problem is easy to trace. Version 8 is now out as well; its upgrade works the same way, with a fresh set of v8_ flags you turn on while still on 7.

Moving from v6 to v7
StepWhat to do
Minimum versionsNode 20, React 18 and React DOM 18
Update v6 firstInstall the latest 6.x so the future flag warnings appear
Turn on future flagsv7_relativeSplatPath, v7_startTransition, v7_fetcherPersist, v7_normalizeFormMethod, v7_partialHydration, v7_skipActionErrorRevalidation
Compare formMethod in capitalsuseNavigation().formMethod is now "POST", not "post"
Drop json() and defer()Return plain objects from loaders, or Response.json() when you need a real response
Swap the packagenpm uninstall react-router-dom, npm install react-router, then change imports to "react-router"
DOM-only importsRouterProvider can also come from "react-router/dom"

How matching works

React Router ranks routes by how specific they are rather than by their order, so /projects/new beats /projects/:projectId no matter which you list first. A * route only wins when nothing more specific matches.

Child paths are relative to their parent. Under a /dashboard route, a child with path settings matches /dashboard/settings, and an index route matches /dashboard itself.

Common mistakes

Mixing imports from react-router-dom and react-router after upgrading, which can load two copies and break context.

Forgetting the Outlet in a layout, so child routes match but nothing appears.

Using ScrollRestoration or useLoaderData with plain BrowserRouter; both need a data router.

Fetching in useEffect when a loader would start the request before the component renders.

How it works

  1. Pick a mode: declarative for simple sites, data mode for loaders and actions, framework mode for a full app with server rendering.
  2. Install react-router and set up the router with the matching example.
  3. Add layouts with child routes and an Outlet, then dynamic segments for detail pages.
  4. Move data fetching into loaders and form posts into actions, and add an error boundary on the root route.
  5. Finish with a * route for unknown URLs and ScrollRestoration in the root layout.

Tips

Frequently asked questions

Is react-router-dom still needed in v7?

No. In v7 everything is exported from react-router. react-router-dom still installs and re-exports the same API to ease upgrades, but new projects only need react-router.

What is the difference between data mode and framework mode?

Data mode is a library: you build the route array yourself with createBrowserRouter. Framework mode adds a Vite plugin that reads app/routes.ts, splits code per route, generates types and can render on the server.

How do I make a 404 page in React Router?

Add a route with path "*" as the last child of your root route. It matches any URL nothing else matched. On a static host, also make the server return index.html for unknown paths so the router gets a chance to show it.

Does useParams return numbers?

No, params are always strings, or undefined when the segment is optional and missing. Convert with Number() and handle the case where the result is not a valid number.

Can I keep v6 code working in v7?

Mostly yes. Turn on all six v7 future flags in v6 first and fix the warnings, then upgrade. The official guide says there are no breaking changes after that apart from the package rename.

Related: React icons guide, Turn a site into a PWA, HTML links, UI components, JSON formatter, All free tools.

Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.

More in Guides

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap