Jesse A. Green / notebook

React Router User Guide

Generated May 2026 from remix-run/react-router at b80380d

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

React Router is the standard routing library for React applications. It supports three distinct usage modes — declarative routing, data routers, and full-stack framework mode — giving you the flexibility to use it as a simple client-side router or a complete application framework with server-side rendering, data loading, and mutations.

This guide covers everything you need to install, configure, and use React Router effectively in any of its modes.


1. Quick Start

1.1 Create a New Project (Framework Mode)

The fastest way to start a new React Router project is with the scaffolding CLI:

npx create-react-router@latest my-app
cd my-app
npm install
npm run dev

This creates a full-stack project with file-based routing, SSR, and the Vite dev server. Open http://localhost:5173 to see your app.

1.2 Add to an Existing React App (Library Mode)

If you already have a React app and want to add routing:

npm install react-router

Then wrap your app with a router:

import { BrowserRouter, Routes, Route, Link } from "react-router";

function App() {
  return (
    <BrowserRouter>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/about">About</Link>
      </nav>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </BrowserRouter>
  );
}

function Home() {
  return <h1>Home</h1>;
}

function About() {
  return <h1>About</h1>;
}

1.3 Data Router (Library Mode with Data APIs)

For apps that need loaders, actions, and data-driven navigation:

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

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    children: [
      {
        index: true,
        element: <Home />,
        loader: async () => {
          const res = await fetch("/api/dashboard");
          return res.json();
        },
      },
    ],
  },
]);

function Root() {
  return (
    <div>
      <nav>
        <Link to="/">Home</Link>
      </nav>
      <Outlet />
    </div>
  );
}

function Home() {
  const data = useLoaderData();
  return <h1>Welcome, {data.user.name}</h1>;
}

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

Tip

If you are starting a new project today, framework mode gives you the most capabilities out of the box — SSR, code splitting, type-safe routing, and data APIs all pre-configured. Use library mode when integrating into an existing app or when you need full control over your build pipeline.


2. Installation

2.1 System Requirements

Requirement Version
Node.js >= 20.0.0
React 18 or 19
Package manager npm, pnpm, yarn, or bun

2.2 Library Mode Installation

Install the core package:

# npm
npm install react-router

# pnpm
pnpm add react-router

# yarn
yarn add react-router

# bun
bun add react-router

Important

The react-router-dom package still exists but is a thin re-export shim. Import everything directly from react-router. The DOM-specific exports (like RouterProvider for the browser) are available from react-router/dom when needed.

2.3 Framework Mode Installation

Framework mode requires the Vite plugin and a server adapter:

# Core + framework
npm install react-router @react-router/dev

# Pick a server adapter (choose one)
npm install @react-router/serve       # Built-in production server
npm install @react-router/express     # Express adapter
npm install @react-router/node        # Node.js streams adapter
npm install @react-router/cloudflare  # Cloudflare Workers/Pages
npm install @react-router/architect   # AWS Architect

2.4 Vite Configuration (Framework Mode)

Framework mode uses Vite as its build tool. Create or update vite.config.ts:

import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [reactRouter()],
});

React Router supports Vite v5, v6, v7-beta, and v8.

2.5 TypeScript Setup

React Router ships with full TypeScript support. For framework mode, the route typegen automatically generates types for your routes:

# Generate route types (framework mode)
npx react-router typegen

Add the generated types to your tsconfig.json:

{
  "compilerOptions": {
    "types": [".react-router/types"]
  }
}

2.6 Package Overview

Package Purpose When to install
react-router Core routing library Always
@react-router/dev Vite plugin + CLI Framework mode
@react-router/serve Production server Framework mode (default server)
@react-router/express Express adapter Framework mode with Express
@react-router/node Node.js adapter Framework mode SSR with Node streams
@react-router/cloudflare Cloudflare adapter Framework mode on Cloudflare
@react-router/architect AWS Architect adapter Framework mode on AWS
@react-router/fs-routes File-system routing convention Framework mode with FS routing
@react-router/remix-routes-option-adapter Migration adapter Migrating from Remix
create-react-router Project scaffolding New projects

3. Configuration

3.1 Framework Mode Configuration (react-router.config.ts)

Framework mode is configured via react-router.config.ts at the project root:

import type { Config } from "@react-router/dev/config";

export default {
  // App source directory (default: "app")
  appDirectory: "app",

  // URL basename (default: "/")
  basename: "/",

  // Build output directory (default: "build")
  buildDirectory: "build",

  // Server build filename (default: "index.js")
  serverBuildFile: "index.js",

  // Module format: "esm" or "cjs" (default: "esm")
  serverModuleFormat: "esm",

  // Enable SSR (default: true, set false for SPA mode)
  ssr: true,
} satisfies Config;

3.2 SPA Mode

Disable server-side rendering to build a single-page application:

import type { Config } from "@react-router/dev/config";

export default {
  ssr: false,
} satisfies Config;

In SPA mode, the build produces a static index.html and client-side JavaScript. No server runtime is required — you can deploy to any static hosting.

3.3 Prerendering

Generate static HTML at build time for specific routes:

import type { Config } from "@react-router/dev/config";

export default {
  // Prerender all routes
  prerender: true,

  // Or prerender specific URLs
  prerender: ["/", "/about", "/blog/intro"],

  // Or compute URLs dynamically
  prerender: async () => {
    const posts = await fetchBlogSlugs();
    return ["/", "/about", ...posts.map((p) => `/blog/${p.slug}`)];
  },
} satisfies Config;

Note

Prerendering is compatible with SSR. Non-prerendered routes will still use server rendering at request time. This is useful for sites where some pages are static (landing, blog) and others are dynamic (dashboard, settings).

3.4 Lazy Route Discovery

React Router can lazily discover routes to reduce initial bundle size:

import type { Config } from "@react-router/dev/config";

export default {
  routeDiscovery: {
    // "lazy" = discover routes as user navigates (default)
    // "initial" = send all routes in the initial response
    mode: "lazy",
  },
} satisfies Config;

In lazy mode, the manifest for unvisited routes is fetched on demand when the user hovers or focuses a link pointing to an undiscovered route.

3.5 Server Bundles

Split your server code into multiple bundles for different deployment targets:

import type { Config } from "@react-router/dev/config";

export default {
  serverBundles({ branch }) {
    // Split into separate bundles based on route tree
    if (branch.some((route) => route.id.startsWith("routes/api"))) {
      return "api";
    }
    return "pages";
  },
} satisfies Config;

3.6 CSRF Protection

Restrict which origins can submit form data to your actions:

import type { Config } from "@react-router/dev/config";

export default {
  allowedActionOrigins: ["https://myapp.com", "https://admin.myapp.com"],
} satisfies Config;

3.7 Future Flags

Opt into upcoming breaking changes early:

import type { Config } from "@react-router/dev/config";

export default {
  future: {
    v8_middleware: true,           // Enable middleware support
    v8_splitRouteModules: true,    // Split route modules for better code splitting
    v8_viteEnvironmentApi: true,   // Use Vite environment API
    unstable_optimizeDeps: true,   // Pre-bundle dependencies
    unstable_passThroughRequests: true,  // Pass non-matched requests through
    unstable_subResourceIntegrity: true, // SRI for scripts/styles
    unstable_trailingSlashAwareDataRequests: true, // Trailing slash handling
  },
} satisfies Config;

3.8 Route Configuration (routes.ts)

In framework mode, define your routes in app/routes.ts:

import { route, index, layout, prefix } from "@react-router/dev/routes";

export default [
  // Index route at "/"
  index("routes/home.tsx"),

  // Regular route at "/about"
  route("about", "routes/about.tsx"),

  // Nested routes under "/dashboard"
  route("dashboard", "routes/dashboard/layout.tsx", [
    index("routes/dashboard/index.tsx"),
    route("settings", "routes/dashboard/settings.tsx"),
    route("profile", "routes/dashboard/profile.tsx"),
  ]),

  // Layout route (no URL segment, wraps children)
  layout("routes/auth-layout.tsx", [
    route("login", "routes/login.tsx"),
    route("register", "routes/register.tsx"),
  ]),

  // API routes with a prefix
  ...prefix("api", [
    route("users", "routes/api/users.tsx"),
    route("posts", "routes/api/posts.tsx"),
  ]),
];

Route Configuration API Reference

Function Description
route(path, file, children?) Define a route with a URL path segment
index(file) Define an index route (matches parent path exactly)
layout(file, children?) Define a layout route (no URL segment, wraps children)
prefix(path, children) Add a path prefix to a set of routes
relative(dir, routes) Resolve route file paths relative to a directory
getAppDirectory() Get the configured app directory path

3.9 File-System Routing

Instead of manual route configuration, use the filesystem convention:

npm install @react-router/fs-routes
// app/routes.ts
import { flatRoutes } from "@react-router/fs-routes";

export default flatRoutes();

The filesystem convention uses the app/routes/ directory:

File URL
routes/_index.tsx /
routes/about.tsx /about
routes/blog.tsx /blog (layout)
routes/blog._index.tsx /blog (index)
routes/blog.$slug.tsx /blog/:slug
routes/blog.($slug).tsx /blog/:slug? (optional)
routes/files.$.tsx /files/* (splat)
routes/docs.[sitemap.xml].tsx /docs/sitemap.xml (escaped)
routes/_auth.tsx (pathless layout)
routes/_auth.login.tsx /login (child of pathless layout)

Key conventions:

  • $param — dynamic URL segment
  • ($param) — optional dynamic segment
  • [literal] — escaped literal (for dots, brackets)
  • _index — index route
  • _ prefix — pathless layout (no URL segment)
  • . — path separator (replaces /)
  • Supported extensions: .js, .jsx, .ts, .tsx, .md, .mdx

3.10 Library Mode Configuration

Library mode (declarative or data router) requires no configuration file. You configure routing entirely in code. For the BrowserRouter, the basename prop serves the same purpose as the framework config option:

<BrowserRouter basename="/app">
  <Routes>
    <Route path="/" element={<Home />} />
  </Routes>
</BrowserRouter>

3.11 Post-Build Hook

Run custom logic after the build completes:

import type { Config } from "@react-router/dev/config";

export default {
  async buildEnd({ buildManifest, reactRouterConfig, viteConfig }) {
    // Generate a sitemap, copy assets, notify a service, etc.
    await generateSitemap(buildManifest);
  },
} satisfies Config;

3.12 Plugin Presets

Extend configuration with reusable presets:

import type { Config } from "@react-router/dev/config";
import vercel from "@vercel/react-router/preset";

export default {
  presets: [vercel()],
} satisfies Config;

Presets can provide default configuration values, routes, and server adapters that are merged with your own config.


4. Core Usage

4.1 Understanding the Three Modes

React Router offers three progressively richer usage modes:

Mode Router Route Definition Data APIs SSR
Declarative BrowserRouter JSX <Route> elements No No
Data Router createBrowserRouter Route objects Yes Manual
Framework @react-router/dev routes.ts or filesystem Yes Built-in

Each mode builds on the previous one. Declarative mode provides components and hooks for URL-based rendering. Data Router mode adds loaders, actions, and pending states. Framework mode adds SSR, code splitting, and the build pipeline.

4.2 Declarative Routing

Basic Setup

import { BrowserRouter, Routes, Route, Link } from "react-router";

function App() {
  return (
    <BrowserRouter>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/products">Products</Link>
        <Link to="/about">About</Link>
      </nav>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/products" element={<Products />} />
        <Route path="/products/:id" element={<ProductDetail />} />
        <Route path="/about" element={<About />} />
        <Route path="*" element={<NotFound />} />
      </Routes>
    </BrowserRouter>
  );
}

Nested Routes

Nested routes allow you to compose layouts with child content areas. Parent routes render an <Outlet /> where child routes appear:

import { BrowserRouter, Routes, Route, Link, Outlet } from "react-router";

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Layout />}>
          <Route index element={<Home />} />
          <Route path="dashboard" element={<Dashboard />}>
            <Route index element={<DashboardHome />} />
            <Route path="settings" element={<Settings />} />
            <Route path="profile" element={<Profile />} />
          </Route>
          <Route path="*" element={<NotFound />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

function Layout() {
  return (
    <div>
      <header>My App</header>
      {/* Child routes render here */}
      <Outlet />
      <footer>&copy; 2026</footer>
    </div>
  );
}

function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <nav>
        <Link to="">Overview</Link>
        <Link to="settings">Settings</Link>
        <Link to="profile">Profile</Link>
      </nav>
      <Outlet />
    </div>
  );
}

When the URL is /dashboard/settings, the render hierarchy is: Layout > Dashboard > Settings, with each parent rendering its <Outlet /> where the child appears.

Index Routes

An index route renders when the parent route matches exactly (no further path segments):

<Route path="dashboard" element={<Dashboard />}>
  {/* Renders at /dashboard (exact) */}
  <Route index element={<DashboardHome />} />
  {/* Renders at /dashboard/settings */}
  <Route path="settings" element={<Settings />} />
</Route>

Router Types

Router Use Case
BrowserRouter Standard web apps (uses HTML5 history API)
HashRouter When you cannot configure the server for URL rewrites
MemoryRouter Testing, non-browser environments (React Native)
StaticRouter Server-side rendering (manual SSR without framework mode)
// Hash-based routing (URLs like /#/about)
import { HashRouter, Routes, Route } from "react-router";

<HashRouter>
  <Routes>
    <Route path="/" element={<Home />} />
  </Routes>
</HashRouter>
// Memory routing (for tests)
import { MemoryRouter } from "react-router";

<MemoryRouter initialEntries={["/dashboard"]}>
  <Routes>
    <Route path="/dashboard" element={<Dashboard />} />
  </Routes>
</MemoryRouter>
// Static routing (SSR)
import { StaticRouter } from "react-router";

function handleRequest(req) {
  const html = renderToString(
    <StaticRouter location={req.url}>
      <App />
    </StaticRouter>
  );
  return html;
}

4.3 Data Router Mode

Data routers unlock loaders, actions, fetchers, and pending UI — the full data layer.

Creating a Data Router

import {
  createBrowserRouter,
  RouterProvider,
  Outlet,
  Link,
  useLoaderData,
  useNavigation,
} from "react-router";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    errorElement: <ErrorBoundary />,
    children: [
      {
        index: true,
        element: <Home />,
        loader: homeLoader,
      },
      {
        path: "contacts/:contactId",
        element: <Contact />,
        loader: contactLoader,
        action: contactAction,
      },
    ],
  },
]);

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

Note

createBrowserRouter is for browser environments. For hash-based routing use createHashRouter, and for testing use createMemoryRouter. The API is identical across all three; only the history implementation differs.

Router Factory Functions

Function Environment
createBrowserRouter(routes, opts?) Browser (HTML5 history)
createHashRouter(routes, opts?) Browser (hash-based URLs)
createMemoryRouter(routes, opts?) Testing, non-browser
createStaticHandler(routes) SSR request handling
createStaticRouter(handler, context) SSR rendering

Loaders

Loaders fetch data before a route renders. They run on every navigation to that route:

// Define the loader
async function contactLoader({ params, request }) {
  const response = await fetch(`/api/contacts/${params.contactId}`);
  if (!response.ok) {
    throw new Response("Contact not found", { status: 404 });
  }
  return response.json();
}

// Use the data in the component
function Contact() {
  const contact = useLoaderData();
  return (
    <div>
      <h1>{contact.name}</h1>
      <p>{contact.email}</p>
    </div>
  );
}

// Wire them together in the route
{
  path: "contacts/:contactId",
  element: <Contact />,
  loader: contactLoader,
}

Loaders receive two arguments:

Argument Contents
params URL parameters from the route path (e.g., { contactId: "42" })
request A standard Request object with the URL, headers, etc.

Actions

Actions handle data mutations (form submissions, API calls that change state):

async function contactAction({ request, params }) {
  const formData = await request.formData();
  const updates = Object.fromEntries(formData);

  const response = await fetch(`/api/contacts/${params.contactId}`, {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(updates),
  });

  if (!response.ok) {
    return { error: "Failed to update contact" };
  }

  // Returning a redirect navigates after the action completes
  return redirect(`/contacts/${params.contactId}`);
}

function EditContact() {
  const contact = useLoaderData();
  const actionData = useActionData();

  return (
    <Form method="post">
      {actionData?.error && <p className="error">{actionData.error}</p>}
      <input name="name" defaultValue={contact.name} />
      <input name="email" defaultValue={contact.email} />
      <button type="submit">Save</button>
    </Form>
  );
}

After an action completes, React Router automatically revalidates all active loaders so the UI stays in sync with the server.

Error Boundaries

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

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

  if (isRouteErrorResponse(error)) {
    return (
      <div>
        <h1>{error.status} {error.statusText}</h1>
        <p>{error.data}</p>
      </div>
    );
  }

  return (
    <div>
      <h1>Something went wrong</h1>
      <p>{error instanceof Error ? error.message : "Unknown error"}</p>
    </div>
  );
}

// Attach to a route
const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    errorElement: <ErrorBoundary />,
    children: [
      {
        path: "invoices/:id",
        element: <Invoice />,
        loader: invoiceLoader,
        // This error boundary catches errors specific to this route
        errorElement: <InvoiceError />,
      },
    ],
  },
]);

Error boundaries bubble up: if a child route throws and has no errorElement, the parent's error boundary catches it. This lets you set a single root error boundary as a fallback and add granular error handling only where needed.

4.4 Framework Mode

Framework mode provides the richest experience: file-based routing, SSR, automatic code splitting, type-safe routes, and integrated data loading.

Project Structure

my-app/
├── app/
│   ├── root.tsx          # Root layout (HTML shell)
│   ├── routes.ts         # Route definitions
│   └── routes/
│       ├── _index.tsx    # Home page (/)
│       ├── about.tsx     # /about
│       └── blog.$slug.tsx # /blog/:slug
├── react-router.config.ts
├── vite.config.ts
└── package.json

Root Layout (app/root.tsx)

The root layout defines the HTML shell for your application:

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

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

export default function Root() {
  return <Outlet />;
}

The SSR components in the root layout serve specific roles:

Component Purpose
<Meta /> Renders <meta> tags from route meta exports
<Links /> Renders <link> tags from route links exports
<Scripts /> Renders the client-side JavaScript bundles
<ScrollRestoration /> Restores scroll position on navigation

Route Modules

Each route file can export several conventions:

// app/routes/blog.$slug.tsx
import type { Route } from "./+types/blog.$slug";

// Metadata for <head>
export const meta: Route.MetaFunction = ({ data }) => {
  return [
    { title: data.post.title },
    { name: "description", content: data.post.excerpt },
  ];
};

// Link tags for <head>
export const links: Route.LinksFunction = () => {
  return [{ rel: "stylesheet", href: "/blog.css" }];
};

// HTTP response headers
export const headers: Route.HeadersFunction = () => {
  return {
    "Cache-Control": "public, max-age=3600",
  };
};

// Server-side data loading
export async function loader({ params }: Route.LoaderArgs) {
  const post = await db.posts.findBySlug(params.slug);
  if (!post) throw new Response("Not found", { status: 404 });
  return { post };
}

// Server-side mutation handling
export async function action({ request, params }: Route.ActionArgs) {
  const formData = await request.formData();
  await db.comments.create({
    postSlug: params.slug,
    body: formData.get("body") as string,
  });
  return { ok: true };
}

// Default export = the page component
export default function BlogPost({ loaderData }: Route.ComponentProps) {
  const { post } = loaderData;
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
    </article>
  );
}

// Error boundary for this route
export function ErrorBoundary() {
  const error = useRouteError();
  return <div>Error loading post</div>;
}

Route Module Exports Summary

Export Purpose
default The route component
loader Server-side data loading (runs before render)
action Server-side mutation handling (form submissions)
clientLoader Client-side data loading
clientAction Client-side mutation handling
meta Document <meta> tags
links Document <link> tags
headers HTTP response headers
handle Arbitrary data exposed via useMatches
ErrorBoundary Error UI for this route
HydrateFallback UI shown during client hydration
shouldRevalidate Control when loaders re-run

Client Loaders and Actions

Run loaders and actions on the client side (in addition to or instead of server):

// Client-side loader (runs in the browser)
export async function clientLoader({ serverLoader }: Route.ClientLoaderArgs) {
  // Check cache first
  const cached = sessionStorage.getItem("dashboard-data");
  if (cached) return JSON.parse(cached);

  // Fall back to server loader
  const data = await serverLoader();
  sessionStorage.setItem("dashboard-data", JSON.stringify(data));
  return data;
}

// Tell React Router this route needs the client loader to hydrate
clientLoader.hydrate = true;

// Client-side action
export async function clientAction({
  request,
  serverAction,
}: Route.ClientActionArgs) {
  const formData = await request.formData();

  // Client-side validation
  if (!formData.get("email")) {
    return { error: "Email is required" };
  }

  // Forward to server
  return serverAction();
}

Tip

Client loaders are powerful for caching strategies. Use them to implement stale-while-revalidate patterns, read from IndexedDB or localStorage, or skip the server entirely for data that only exists on the client.

4.5 Navigation Components

The basic navigation component. Renders an <a> tag that performs client-side navigation:

import { Link } from "react-router";

// Basic link
<Link to="/about">About</Link>

// Relative link (relative to current route)
<Link to="settings">Settings</Link>

// Link with state (accessible via useLocation().state)
<Link to="/login" state={{ from: "/dashboard" }}>Login</Link>

// Replace history entry (no back button)
<Link to="/welcome" replace>Welcome</Link>

// Prevent scroll reset on navigation
<Link to="/tab2" preventScrollReset>Tab 2</Link>

// Link with view transition animation
<Link to="/next" viewTransition>Next Page</Link>

// Relative to path (not route hierarchy)
<Link to=".." relative="path">Back</Link>

An enhanced Link that knows whether it is active, pending, or transitioning:

import { NavLink } from "react-router";

// className as function
<NavLink
  to="/dashboard"
  className={({ isActive, isPending, isTransitioning }) =>
    [
      isPending ? "pending" : "",
      isActive ? "active" : "",
      isTransitioning ? "transitioning" : "",
    ].join(" ")
  }
>
  Dashboard
</NavLink>

// style as function
<NavLink
  to="/messages"
  style={({ isActive }) => ({
    fontWeight: isActive ? "bold" : "normal",
    color: isActive ? "#1a73e8" : "inherit",
  })}
>
  Messages
</NavLink>

// Children as render function
<NavLink to="/tasks">
  {({ isActive }) => (
    <span>{isActive ? ">> Tasks" : "Tasks"}</span>
  )}
</NavLink>

// End prop — only match exact path, not descendant routes
<NavLink to="/dashboard" end>Dashboard Home</NavLink>

The end prop is important for parent routes. Without it, <NavLink to="/dashboard"> would be active on /dashboard/settings too. With end, it is only active on exactly /dashboard.

Form

The Form component provides declarative data mutations. It works like a standard HTML <form> but intercepts the submission to perform client-side navigation and trigger route actions:

import { Form } from "react-router";

// POST form (triggers route action)
<Form method="post">
  <input name="title" />
  <textarea name="body" />
  <button type="submit">Create Post</button>
</Form>

// GET form (navigates with query string)
<Form method="get" action="/search">
  <input name="q" placeholder="Search..." />
  <button type="submit">Search</button>
</Form>

// DELETE-style mutation (use POST with intent)
<Form method="post">
  <input type="hidden" name="intent" value="delete" />
  <input type="hidden" name="id" value={item.id} />
  <button type="submit">Delete</button>
</Form>

// File upload
<Form method="post" encType="multipart/form-data">
  <input type="file" name="avatar" />
  <button type="submit">Upload</button>
</Form>

// Navigate without reloading
<Form method="post" navigate={false}>
  {/* Submits without navigating — useful with useFetcher alternative */}
</Form>

Redirect on render (component-based redirect):

import { Navigate } from "react-router";

function ProtectedRoute({ children }) {
  const { user } = useAuth();
  if (!user) {
    return <Navigate to="/login" replace />;
  }
  return children;
}

Outlet

Renders the matching child route. Optionally pass context data to children:

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

function Dashboard() {
  const [user, setUser] = useState(null);

  return (
    <div>
      <Sidebar />
      {/* Pass context to child routes */}
      <Outlet context={{ user, setUser }} />
    </div>
  );
}

// In a child route:
function DashboardSettings() {
  // Type the context for TypeScript safety
  const { user, setUser } = useOutletContext<{
    user: User | null;
    setUser: (u: User) => void;
  }>();

  return <SettingsForm user={user} onSave={setUser} />;
}

ScrollRestoration

Restores scroll position when navigating back/forward. Place it once in your root layout (data router or framework mode only):

import { ScrollRestoration } from "react-router";

// In your root layout
function Root() {
  return (
    <>
      <Outlet />
      <ScrollRestoration />
    </>
  );
}

// Custom scroll key — for example, scroll by pathname only, ignoring search params
<ScrollRestoration
  getKey={(location) => location.pathname}
/>

// Restore scroll per-element (not just window)
<ScrollRestoration
  getKey={(location) => {
    // Use different keys for different "tabs" at the same URL
    return location.pathname + location.search;
  }}
/>

Await

Render deferred data with a Suspense-compatible loading fallback:

import { Await, useLoaderData } from "react-router";
import { Suspense } from "react";

export async function loader() {
  return {
    user: await getUser(),                    // Awaited before render
    recommendations: getRecommendations(),    // Promise passed through (deferred)
  };
}

export default function Dashboard() {
  const { user, recommendations } = useLoaderData();

  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <Suspense fallback={<p>Loading recommendations...</p>}>
        <Await resolve={recommendations}>
          {(items) => (
            <ul>
              {items.map((item) => (
                <li key={item.id}>{item.title}</li>
              ))}
            </ul>
          )}
        </Await>
      </Suspense>
    </div>
  );
}

The key insight: if you await a promise in the loader, the page waits for it before rendering. If you return the promise without await, the page renders immediately and the <Await> component streams in the data when it resolves.

4.6 Navigation Hooks

useNavigate

Programmatic navigation:

import { useNavigate } from "react-router";

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

  async function handleSubmit(event) {
    event.preventDefault();
    const success = await login(/* ... */);
    if (success) {
      navigate("/dashboard");
    }
  }

  return <form onSubmit={handleSubmit}>...</form>;
}

// Navigation options
navigate("/settings");                    // Push new entry
navigate("/settings", { replace: true }); // Replace current entry
navigate(-1);                             // Go back
navigate(-2);                             // Go back 2 entries
navigate(1);                              // Go forward
navigate("/login", {
  state: { from: location.pathname },     // Pass state data
});
navigate("/new-page", {
  viewTransition: true,                   // Animate the transition
});

useLocation

Access the current location object:

import { useLocation } from "react-router";

function Analytics() {
  const location = useLocation();

  useEffect(() => {
    trackPageView(location.pathname);
  }, [location]);

  return null;
}

The location object contains:

Property Example Description
pathname "/products/42" Current URL path
search "?color=red" Query string (including ?)
hash "#details" Hash fragment (including #)
state { from: "/" } State passed via navigation
key "default" Unique key for this history entry

useParams

Access dynamic URL parameters:

import { useParams } from "react-router";

// Route: "/users/:userId/posts/:postId"
// URL: "/users/123/posts/456"

function Post() {
  const { userId, postId } = useParams();
  // userId === "123"
  // postId === "456"

  return <h1>Post {postId} by User {userId}</h1>;
}

All parameter values are strings. Parse numbers or other types explicitly:

const { id } = useParams();
const numericId = Number(id); // Convert when needed

useSearchParams

Read and update query string parameters without a full navigation:

import { useSearchParams } from "react-router";

function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams();

  const category = searchParams.get("category");  // "shoes" or null
  const page = searchParams.get("page") || "1";

  function nextPage() {
    setSearchParams((prev) => {
      prev.set("page", String(Number(page) + 1));
      return prev;
    });
  }

  function setCategory(cat: string) {
    setSearchParams({ category: cat, page: "1" });
  }

  return (
    <div>
      <h1>Products{category ? `: ${category}` : ""} (Page {page})</h1>
      <button onClick={() => setCategory("shoes")}>Shoes</button>
      <button onClick={() => setCategory("hats")}>Hats</button>
      <button onClick={nextPage}>Next Page</button>
    </div>
  );
}

useSearchParams returns a tuple of [URLSearchParams, SetURLSearchParams], following the same pattern as React's useState.

useNavigation

Track the state of any in-progress navigation:

import { useNavigation } from "react-router";

function Root() {
  const navigation = useNavigation();

  return (
    <div>
      {navigation.state === "loading" && <GlobalSpinner />}
      {navigation.state === "submitting" && <p>Saving...</p>}
      <Outlet />
    </div>
  );
}
Property Type Description
state "idle" | "loading" | "submitting" Current navigation state
location Location | undefined Target location (when loading)
formData FormData | undefined Submitted form data (when submitting)
formAction string | undefined Action URL being submitted to
formMethod string | undefined HTTP method of the submission
formEncType string | undefined Encoding type of the submission

useBlocker

Block navigation when the user has unsaved changes:

import { useBlocker } from "react-router";

function EditForm() {
  const [isDirty, setIsDirty] = useState(false);
  const blocker = useBlocker(isDirty);

  return (
    <div>
      <form onChange={() => setIsDirty(true)}>
        <input name="title" />
        <button type="submit">Save</button>
      </form>

      {blocker.state === "blocked" && (
        <div className="modal">
          <p>You have unsaved changes. Leave anyway?</p>
          <button onClick={() => blocker.proceed()}>Leave</button>
          <button onClick={() => blocker.reset()}>Stay</button>
        </div>
      )}
    </div>
  );
}

The blocker has three states: "unblocked" (default), "blocked" (navigation attempted), and "proceeding" (user confirmed navigation).

useBeforeUnload

Warn before the user closes or reloads the browser tab:

import { useBeforeUnload } from "react-router";
import { useCallback } from "react";

function EditForm() {
  const [isDirty, setIsDirty] = useState(false);

  useBeforeUnload(
    useCallback(
      (event) => {
        if (isDirty) {
          event.preventDefault();
        }
      },
      [isDirty]
    )
  );

  return <form onChange={() => setIsDirty(true)}>...</form>;
}

Warning

useBeforeUnload only handles browser tab close/reload. For in-app navigation blocking, use useBlocker instead. You typically want both for full coverage.

4.7 Data Hooks

useLoaderData

Access the data returned by the current route's loader:

import { useLoaderData } from "react-router";

export async function loader() {
  const users = await db.users.findMany();
  return { users };
}

export default function Users() {
  const { users } = useLoaderData();
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

In framework mode with typegen, the return type is inferred automatically from your loader function.

useActionData

Access the data returned by the current route's action (after a form submission):

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

export async function action({ request }) {
  const formData = await request.formData();
  const email = formData.get("email") as string;

  const errors: Record<string, string> = {};
  if (!email) errors.email = "Email is required";
  else if (!email.includes("@")) errors.email = "Invalid email";

  if (Object.keys(errors).length > 0) {
    return { errors };
  }

  await createSubscriber(email);
  return { success: true };
}

export default function Newsletter() {
  const actionData = useActionData();

  return (
    <Form method="post">
      <label>
        Email
        <input name="email" type="email" />
      </label>
      {actionData?.errors?.email && (
        <p className="error">{actionData.errors.email}</p>
      )}
      {actionData?.success && <p className="success">Subscribed!</p>}
      <button type="submit">Subscribe</button>
    </Form>
  );
}

useActionData returns undefined on first render (no submission yet), then returns the action's return value after submission.

useRouteLoaderData

Access loader data from a parent or any ancestor route by its route ID:

import { useRouteLoaderData } from "react-router";

// In any child route component:
function UserAvatar() {
  // Access the root route's loader data
  // In data router mode, the route ID is the path or an explicit `id` property
  const rootData = useRouteLoaderData("root");
  if (!rootData?.user) return null;
  return <img src={rootData.user.avatarUrl} alt={rootData.user.name} />;
}

This is useful for accessing global data (current user, feature flags, site config) loaded in a root or layout route from deeply nested children.

useFetcher

Interact with loaders and actions without causing a navigation. This is the workhorse for in-place updates, auto-save, background operations, and any mutation that should not change the URL:

import { useFetcher } from "react-router";

function TodoItem({ todo }) {
  const fetcher = useFetcher();

  // Optimistic UI: use fetcher's submitted data if available
  const isComplete = fetcher.formData
    ? fetcher.formData.get("complete") === "true"
    : todo.complete;

  return (
    <li className={isComplete ? "completed" : ""}>
      <fetcher.Form method="post" action={`/todos/${todo.id}/toggle`}>
        <input type="hidden" name="complete" value={String(!todo.complete)} />
        <button type="submit" disabled={fetcher.state !== "idle"}>
          {isComplete ? "Done" : "Todo"}: {todo.title}
        </button>
      </fetcher.Form>
    </li>
  );
}

Load data without navigation:

function UserSearch() {
  const fetcher = useFetcher();
  const [query, setQuery] = useState("");

  useEffect(() => {
    if (query.length > 2) {
      fetcher.load(`/api/users?q=${encodeURIComponent(query)}`);
    }
  }, [query]);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search users..."
      />
      {fetcher.state === "loading" && <Spinner />}
      {fetcher.data && (
        <ul>
          {fetcher.data.users.map((u) => (
            <li key={u.id}>{u.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
}
Fetcher property Description
fetcher.state "idle", "loading", or "submitting"
fetcher.data The data returned by the loader or action
fetcher.formData The FormData being submitted
fetcher.formAction The action URL
fetcher.formMethod The HTTP method
fetcher.Form A <Form> component bound to this fetcher
fetcher.submit() Programmatic submit function
fetcher.load(url) Load data from a route's loader

useFetchers

Access all active fetchers (useful for global pending indicators):

import { useFetchers } from "react-router";

function GlobalPendingIndicator() {
  const fetchers = useFetchers();
  const isSubmitting = fetchers.some((f) => f.state === "submitting");
  const isLoading = fetchers.some((f) => f.state === "loading");

  if (!isSubmitting && !isLoading) return null;
  return <div className="global-spinner" />;
}

useRevalidator

Manually trigger revalidation of all loader data:

import { useRevalidator } from "react-router";

function RefreshButton() {
  const revalidator = useRevalidator();

  return (
    <button
      onClick={() => revalidator.revalidate()}
      disabled={revalidator.state === "loading"}
    >
      {revalidator.state === "loading" ? "Refreshing..." : "Refresh Data"}
    </button>
  );
}

Common use cases for manual revalidation:

  • Polling on an interval
  • After a WebSocket message indicates server data changed
  • After a user returns to the tab (visibility change)

useSubmit

Submit forms programmatically:

import { useSubmit } from "react-router";

// Auto-save on change
function AutoSaveForm() {
  const submit = useSubmit();

  function handleChange(event) {
    // Submit the entire form on any change
    submit(event.currentTarget.form, {
      method: "post",
      replace: true,
    });
  }

  return (
    <Form method="post">
      <textarea name="content" onChange={handleChange} />
    </Form>
  );
}

// Submit arbitrary data (not from a form)
function DeleteButton({ id }) {
  const submit = useSubmit();

  function handleDelete() {
    if (confirm("Are you sure?")) {
      submit(
        { intent: "delete", id },
        { method: "post", action: "/items" }
      );
    }
  }

  return <button onClick={handleDelete}>Delete</button>;
}

// Submit FormData directly
function FileUpload() {
  const submit = useSubmit();

  function handleFile(event) {
    const formData = new FormData();
    formData.set("file", event.target.files[0]);
    submit(formData, {
      method: "post",
      encType: "multipart/form-data",
    });
  }

  return <input type="file" onChange={handleFile} />;
}

4.8 URL and Path Utilities

useHref

Resolve a to value into a full href string:

import { useHref } from "react-router";

function ExternalForm() {
  // Resolves against the current basename
  const href = useHref("/submit");
  return <form action={href} method="post">...</form>;
}

useResolvedPath

Resolve a relative path against the current location:

import { useResolvedPath } from "react-router";

function Breadcrumb() {
  const resolved = useResolvedPath("..");
  // resolved.pathname — parent route's path
  return <Link to={resolved}>Back</Link>;
}

useMatch

Check if the current URL matches a specific pattern:

import { useMatch } from "react-router";

function AdminBadge() {
  const match = useMatch("/admin/*");
  if (!match) return null;
  return <span className="badge">Admin Area</span>;
}

// Access captured params
function UserBanner() {
  const match = useMatch("/users/:id");
  if (!match) return null;
  return <p>Viewing user {match.params.id}</p>;
}

useMatches

Get all matched routes in the current hierarchy. Each match includes the route's id, pathname, params, data (loader data), and handle:

import { useMatches } from "react-router";

function Breadcrumbs() {
  const matches = useMatches();

  return (
    <nav aria-label="breadcrumb">
      <ol>
        {matches
          .filter((match) => match.handle?.breadcrumb)
          .map((match) => (
            <Link key={match.id} to={match.pathname}>
              {typeof match.handle.breadcrumb === "function"
                ? match.handle.breadcrumb(match)
                : match.handle.breadcrumb}
            </Link>
          ))}
      </ol>
    </nav>
  );
}

useFormAction

Resolve the action URL for the current route:

import { useFormAction } from "react-router";

function InlineAction() {
  const action = useFormAction();
  // Resolves to the current route's action URL
  // Useful for constructing action URLs in nested routes
  return <Form method="post" action={action}>...</Form>;
}

useLinkClickHandler

Build custom navigation components with full control over click handling:

import { useLinkClickHandler } from "react-router";

function TrackedLink({ to, children, ...props }) {
  const handleClick = useLinkClickHandler(to);

  return (
    <a
      href={to}
      onClick={(event) => {
        analytics.track("link_click", { to });
        handleClick(event);
      }}
      {...props}
    >
      {children}
    </a>
  );
}

4.9 View Transitions

React Router integrates with the browser's View Transitions API for animated page transitions:

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

// Enable via Link
<Link to="/gallery/42" viewTransition>View Photo</Link>

// Enable via NavLink
<NavLink to="/page" viewTransition>Page</NavLink>

// Enable programmatically
const navigate = useNavigate();
navigate("/next", { viewTransition: true });

// Check transition state for CSS animations
function ImageCard({ id, src }) {
  const isTransitioning = useViewTransitionState(`/gallery/${id}`);

  return (
    <Link to={`/gallery/${id}`} viewTransition>
      <img
        src={src}
        style={{
          viewTransitionName: isTransitioning ? "hero-image" : "",
        }}
      />
    </Link>
  );
}

Style your transitions with CSS:

::view-transition-old(hero-image) {
  animation: scale-down 0.3s ease-out;
}

::view-transition-new(hero-image) {
  animation: scale-up 0.3s ease-in;
}

@keyframes scale-down {
  from { transform: scale(1); }
  to { transform: scale(0.8); opacity: 0; }
}

@keyframes scale-up {
  from { transform: scale(0.8); opacity: 0; }
  to { transform: scale(1); }
}

4.10 Type-Safe Routing (Framework Mode)

Framework mode generates TypeScript types for every route. Run npx react-router typegen (or configure it to run automatically during development) and import the generated types:

// app/routes/products.$id.tsx
import type { Route } from "./+types/products.$id";

export async function loader({ params }: Route.LoaderArgs) {
  // params.id is typed as string (never undefined, since it's required)
  const product = await getProduct(params.id);
  return { product };
}

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  // Handle mutation
  return { success: true };
}

export default function Product({ loaderData, actionData }: Route.ComponentProps) {
  // loaderData.product is fully typed based on loader return type
  // actionData is typed based on action return type (or undefined)
  return <h1>{loaderData.product.name}</h1>;
}

The href() utility provides type-safe path construction:

import { href } from "react-router";

// TypeScript errors if the route pattern or params are wrong
<Link to={href("/products/:id", { id: "42" })}>Product 42</Link>

// No params needed for static routes
<Link to={href("/about")}>About</Link>

// Optional params
<Link to={href("/blog/:slug?", { slug: "intro" })}>Blog Post</Link>
<Link to={href("/blog/:slug?")}>Blog Index</Link>

4.11 Standalone Utility Functions

These functions work outside of React components — in loaders, actions, tests, or plain JavaScript.

Path Matching and Generation

import { matchPath, matchRoutes, generatePath } from "react-router";

// Match a single path pattern against a URL
const match = matchPath("/users/:id", "/users/42");
if (match) {
  console.log(match.params.id); // "42"
  console.log(match.pathname);  // "/users/42"
  console.log(match.pattern);   // { path: "/users/:id" }
}

// Match with options
matchPath({ path: "/users/:id", end: false }, "/users/42/posts");
// Matches (end: false allows trailing segments)

// Match against a route tree
const routes = [
  {
    path: "/",
    children: [
      { path: "users/:id" },
      { path: "posts/:postId" },
    ],
  },
];
const matches = matchRoutes(routes, "/users/42");
// Returns array of matched route objects with params

// Generate a path from a pattern + params
const path = generatePath("/users/:id/posts/:postId", {
  id: "42",
  postId: "7",
});
// Returns "/users/42/posts/7"

// Splat params
generatePath("/files/*", { "*": "docs/readme.md" });
// Returns "/files/docs/readme.md"

Path Manipulation

import { createPath, parsePath, resolvePath } from "react-router";

// Create a path string from parts
createPath({
  pathname: "/users",
  search: "?page=2",
  hash: "#top",
});
// Returns "/users?page=2#top"

// Parse a path string into parts
parsePath("/users?page=2#top");
// Returns { pathname: "/users", search: "?page=2", hash: "#top" }

// Resolve a relative path against a base
resolvePath("settings", "/dashboard");
// Returns { pathname: "/dashboard/settings", search: "", hash: "" }

resolvePath("..", "/dashboard/settings");
// Returns { pathname: "/dashboard", search: "", hash: "" }

Response Helpers

Use these in loaders and actions to create standardized responses:

import { redirect, redirectDocument, replace, data } from "react-router";

// Client-side redirect (SPA navigation)
export async function action() {
  await saveData();
  return redirect("/success");
}

// Redirect with status code
return redirect("/login", 302);

// Redirect with headers
return redirect("/dashboard", {
  headers: { "Set-Cookie": await commitSession(session) },
});

// Full document redirect (bypasses client-side routing, full page reload)
export async function action() {
  return redirectDocument("/external-page");
}

// Replace current history entry (navigates but no new history entry)
export async function action() {
  return replace("/dashboard");
}

// Typed response with custom status and headers
export async function loader() {
  return data(
    { items: await getItems() },
    {
      status: 200,
      headers: {
        "Cache-Control": "public, max-age=3600",
        "X-Custom-Header": "value",
      },
    }
  );
}

// Error responses (caught by error boundaries)
export async function loader({ params }) {
  const item = await getItem(params.id);
  if (!item) {
    throw data(
      { message: "Item not found" },
      { status: 404 }
    );
  }
  return { item };
}

Route Conversion Utilities

import { createRoutesFromElements } from "react-router";

// Convert JSX route elements to route objects
// Useful if you have JSX routes but need to pass them to createBrowserRouter
const routes = createRoutesFromElements(
  <Route path="/" element={<Root />}>
    <Route index element={<Home />} />
    <Route path="about" element={<About />} />
  </Route>
);

const router = createBrowserRouter(routes);

Search Params Factory

import { createSearchParams } from "react-router";

// From an object
const params = createSearchParams({ page: "1", sort: "name" });
params.toString(); // "page=1&sort=name"

// From entries array (supports repeated keys)
const tags = createSearchParams([
  ["tag", "react"],
  ["tag", "router"],
  ["tag", "typescript"],
]);
tags.toString(); // "tag=react&tag=router&tag=typescript"

// Use with navigate
const navigate = useNavigate();
navigate({
  pathname: "/search",
  search: createSearchParams({ q: "react router" }).toString(),
});

4.12 Server-Side Utilities (Framework Mode)

Request Handler

Create a request handler for your server adapter:

import { createRequestHandler } from "react-router";

// Express adapter
import express from "express";

const app = express();

app.all(
  "*",
  createRequestHandler({
    build: () => import("./build/server/index.js"),
  })
);

app.listen(3000);

Session Management

React Router provides session utilities for server-side session handling:

import {
  createCookieSessionStorage,
  createCookie,
  redirect,
} from "react-router";

// Create a session storage backed by cookies
const { getSession, commitSession, destroySession } =
  createCookieSessionStorage({
    cookie: {
      name: "__session",
      httpOnly: true,
      maxAge: 60 * 60 * 24 * 7, // 1 week
      path: "/",
      sameSite: "lax",
      secrets: [process.env.SESSION_SECRET],
      secure: process.env.NODE_ENV === "production",
    },
  });

// Use in a loader
export async function loader({ request }) {
  const session = await getSession(request.headers.get("Cookie"));
  const userId = session.get("userId");
  if (!userId) throw redirect("/login");

  const user = await getUser(userId);
  return { user };
}

// Use in an action (login)
export async function action({ request }) {
  const formData = await request.formData();
  const user = await authenticate(
    formData.get("email"),
    formData.get("password")
  );

  if (!user) {
    return { error: "Invalid credentials" };
  }

  const session = await getSession(request.headers.get("Cookie"));
  session.set("userId", user.id);

  return redirect("/dashboard", {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
}

// Logout action
export async function action({ request }) {
  const session = await getSession(request.headers.get("Cookie"));
  return redirect("/login", {
    headers: {
      "Set-Cookie": await destroySession(session),
    },
  });
}

Available session storage implementations:

Storage Use Case
createCookieSessionStorage Store session data in encrypted cookies
createMemorySessionStorage In-memory storage (development, testing)
createSessionStorage Custom backend (database, Redis, file system)

Flash Messages with Sessions

export async function action({ request }) {
  const session = await getSession(request.headers.get("Cookie"));

  // Flash messages are removed after first read
  session.flash("success", "Profile updated successfully");

  return redirect("/profile", {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
}

export async function loader({ request }) {
  const session = await getSession(request.headers.get("Cookie"));
  const success = session.get("success"); // Reads and removes the flash

  return data(
    { success },
    {
      headers: {
        "Set-Cookie": await commitSession(session),
      },
    }
  );
}

Standalone Cookies

For simpler use cases that do not need full session management:

import { createCookie } from "react-router";

const userPrefs = createCookie("prefs", {
  maxAge: 60 * 60 * 24 * 365, // 1 year
});

// Read
export async function loader({ request }) {
  const prefs = await userPrefs.parse(request.headers.get("Cookie"));
  return { theme: prefs?.theme || "light" };
}

// Write
export async function action({ request }) {
  const formData = await request.formData();
  const prefs = { theme: formData.get("theme") };
  return redirect("/settings", {
    headers: {
      "Set-Cookie": await userPrefs.serialize(prefs),
    },
  });
}

4.13 Middleware (v8 Future Flag)

Warning

Middleware requires the v8_middleware future flag and its API may change before the v8 stable release. Enable it with future: { v8_middleware: true } in react-router.config.ts.

import { createContext } from "react-router";

// Create a typed context key
type AuthContext = { userId: string; role: string };
const authContext = createContext<AuthContext>();

// Define middleware in a route module
export const middleware = [
  async function auth({ request, context }) {
    const session = await getSession(request.headers.get("Cookie"));
    const userId = session.get("userId");
    if (!userId) throw redirect("/login");

    const user = await getUser(userId);
    context.set(authContext, { userId: user.id, role: user.role });
  },
];

// Access context in loaders and actions
export async function loader({ context }) {
  const { userId, role } = context.get(authContext);
  if (role !== "admin") throw new Response("Forbidden", { status: 403 });
  return await getAdminData(userId);
}

Middleware runs in order before the route's loader/action. Use RouterContextProvider in the root to make context available throughout the app:

import { RouterContextProvider } from "react-router";

export default function Root() {
  return (
    <RouterContextProvider>
      <Outlet />
    </RouterContextProvider>
  );
}

5. Common Tasks

5.1 Authentication and Protected Routes

Redirect Unauthenticated Users (Data Router / Framework Mode)

// In a layout route loader that wraps protected routes
export async function loader({ request }) {
  const user = await getUser(request);
  if (!user) {
    const url = new URL(request.url);
    throw redirect(`/login?returnTo=${encodeURIComponent(url.pathname)}`);
  }
  return { user };
}

Redirect Unauthenticated Users (Declarative Mode)

function RequireAuth({ children }) {
  const { user } = useAuth();
  const location = useLocation();

  if (!user) {
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  return children;
}

// Usage in routes
<Route
  path="/dashboard/*"
  element={
    <RequireAuth>
      <Dashboard />
    </RequireAuth>
  }
/>

Redirect After Login

// Login action (framework/data router)
export async function action({ request }) {
  const formData = await request.formData();
  const user = await authenticate(
    formData.get("email"),
    formData.get("password")
  );

  if (!user) {
    return { error: "Invalid credentials" };
  }

  const url = new URL(request.url);
  const returnTo = url.searchParams.get("returnTo") || "/dashboard";

  const session = await getSession(request.headers.get("Cookie"));
  session.set("userId", user.id);

  return redirect(returnTo, {
    headers: { "Set-Cookie": await commitSession(session) },
  });
}

Role-Based Access Control

export async function loader({ request }) {
  const user = await requireUser(request);

  if (!user.roles.includes("admin")) {
    throw new Response("Forbidden", { status: 403 });
  }

  return { adminData: await getAdminDashboard() };
}

5.2 Code Splitting with Lazy Routes

Data Router Lazy Loading

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    children: [
      {
        path: "dashboard",
        lazy: async () => {
          const { Dashboard, loader, action } = await import("./routes/dashboard");
          return { Component: Dashboard, loader, action };
        },
      },
      {
        path: "settings",
        // If the module has default exports matching the route API,
        // they are picked up automatically
        lazy: () => import("./routes/settings"),
      },
    ],
  },
]);

The lazy function can return any route property except path, index, children, and id. Common properties to return: Component, loader, action, errorElement, handle, shouldRevalidate.

Declarative Lazy Loading

import { lazy, Suspense } from "react";

const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div>Loading...</div>}>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Tip

In framework mode, code splitting is automatic. Each route module is compiled into its own chunk and loaded on demand during navigation. No manual lazy() calls are needed.

5.3 Search and Filtering

Implement search with GET forms and loaders for URL-driven, bookmarkable, shareable searches:

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

export async function loader({ request }) {
  const url = new URL(request.url);
  const q = url.searchParams.get("q") || "";
  const category = url.searchParams.get("category") || "all";
  const page = Number(url.searchParams.get("page") || "1");

  const { products, total } = await searchProducts({ q, category, page });
  return { products, total, q, category, page };
}

export default function Search() {
  const { products, total, q, category, page } = useLoaderData();
  const navigation = useNavigation();
  const [searchParams, setSearchParams] = useSearchParams();

  const isSearching = navigation.state === "loading";

  return (
    <div>
      {/* GET form — submits to the current URL with query params */}
      <Form method="get">
        <input name="q" defaultValue={q} placeholder="Search products..." />
        <select name="category" defaultValue={category}>
          <option value="all">All Categories</option>
          <option value="electronics">Electronics</option>
          <option value="clothing">Clothing</option>
          <option value="books">Books</option>
        </select>
        <button type="submit">Search</button>
      </Form>

      <div className={isSearching ? "loading" : ""}>
        <p>{total} results</p>
        {products.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>

      {/* Pagination */}
      <button
        disabled={page <= 1}
        onClick={() => {
          setSearchParams((prev) => {
            prev.set("page", String(page - 1));
            return prev;
          });
        }}
      >
        Previous
      </button>
      <button
        onClick={() => {
          setSearchParams((prev) => {
            prev.set("page", String(page + 1));
            return prev;
          });
        }}
      >
        Next
      </button>
    </div>
  );
}

5.4 Optimistic UI

Update the UI immediately before the server responds, then reconcile when the response arrives:

function FavoriteButton({ item }) {
  const fetcher = useFetcher();

  // Use the optimistic value while the request is in flight
  const isFavorite = fetcher.formData
    ? fetcher.formData.get("favorite") === "true"
    : item.isFavorite;

  return (
    <fetcher.Form method="post" action="/api/favorites">
      <input type="hidden" name="itemId" value={item.id} />
      <input type="hidden" name="favorite" value={String(!isFavorite)} />
      <button
        type="submit"
        aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
      >
        {isFavorite ? "Remove Favorite" : "Add Favorite"}
      </button>
    </fetcher.Form>
  );
}

Optimistic List Updates

function TodoList() {
  const { todos } = useLoaderData();
  const fetchers = useFetchers();

  // Merge optimistic updates into the list
  const optimisticTodos = todos.map((todo) => {
    const fetcher = fetchers.find(
      (f) => f.formData?.get("todoId") === todo.id
    );
    if (fetcher?.formData) {
      return {
        ...todo,
        title: fetcher.formData.get("title") || todo.title,
        complete: fetcher.formData.get("complete") === "true",
      };
    }
    return todo;
  });

  return (
    <ul>
      {optimisticTodos.map((todo) => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

5.5 Pending UI (Loading and Submitting States)

Global Loading Indicator

import { useNavigation } from "react-router";

function Root() {
  const navigation = useNavigation();

  return (
    <div>
      {navigation.state !== "idle" && (
        <div className="progress-bar" aria-label="Loading" />
      )}
      <Outlet />
    </div>
  );
}

Busy Submit Button

function SubmitButton({ label = "Save" }) {
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";

  return (
    <button type="submit" disabled={isSubmitting}>
      {isSubmitting ? "Saving..." : label}
    </button>
  );
}

Skeleton UI While Loading a Specific Route

function DashboardLink() {
  const navigation = useNavigation();

  const isLoadingDashboard =
    navigation.state === "loading" &&
    navigation.location?.pathname === "/dashboard";

  return (
    <div>
      <Link to="/dashboard">Dashboard</Link>
      {isLoadingDashboard && <Spinner />}
    </div>
  );
}

5.6 Error Handling Patterns

Granular Error Boundaries

Place error boundaries at different levels for targeted error recovery:

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    errorElement: <RootError />,        // Last resort
    children: [
      {
        path: "dashboard",
        element: <Dashboard />,
        errorElement: <DashboardError />, // Dashboard-specific recovery
        children: [
          {
            path: "analytics",
            element: <Analytics />,
            // No errorElement — errors bubble up to DashboardError
          },
          {
            path: "billing",
            element: <Billing />,
            errorElement: <BillingError />, // Billing-specific recovery
          },
        ],
      },
    ],
  },
]);

Throwing Responses for HTTP Errors

export async function loader({ params }) {
  const user = await getUser(params.id);
  if (!user) {
    throw new Response("User not found", { status: 404 });
  }
  if (!user.isPublic) {
    throw new Response("Forbidden", { status: 403 });
  }
  return { user };
}

// Error boundary with HTTP status handling
function ErrorBoundary() {
  const error = useRouteError();

  if (isRouteErrorResponse(error)) {
    switch (error.status) {
      case 404:
        return (
          <div>
            <h1>Not Found</h1>
            <p>The resource you requested does not exist.</p>
            <Link to="/">Go Home</Link>
          </div>
        );
      case 403:
        return (
          <div>
            <h1>Access Denied</h1>
            <p>You do not have permission to view this page.</p>
          </div>
        );
      default:
        return (
          <div>
            <h1>Error {error.status}</h1>
            <p>{error.statusText}</p>
          </div>
        );
    }
  }

  // Non-Response errors (runtime errors, network failures)
  return (
    <div>
      <h1>Something went wrong</h1>
      <p>{error instanceof Error ? error.message : "Unknown error"}</p>
    </div>
  );
}

Retry After Error

function ErrorWithRetry() {
  const error = useRouteError();
  const revalidator = useRevalidator();

  return (
    <div>
      <h1>Failed to load data</h1>
      <p>{error instanceof Error ? error.message : "Unknown error"}</p>
      <button onClick={() => revalidator.revalidate()}>
        Try Again
      </button>
    </div>
  );
}

5.7 Modals as Routes

Render modals with shareable URLs while keeping the background page visible:

function App() {
  const location = useLocation();
  const state = location.state;

  return (
    <>
      {/* Always render the background page */}
      <Routes location={state?.backgroundLocation || location}>
        <Route path="/" element={<Layout />}>
          <Route index element={<PhotoGrid />} />
          <Route path="photos/:id" element={<PhotoPage />} />
        </Route>
      </Routes>

      {/* Render the modal overlay when navigated from within the app */}
      {state?.backgroundLocation && (
        <Routes>
          <Route path="photos/:id" element={<PhotoModal />} />
        </Routes>
      )}
    </>
  );
}

// Grid links that open modals
function PhotoGrid() {
  const location = useLocation();

  return (
    <div className="grid">
      {photos.map((photo) => (
        <Link
          key={photo.id}
          to={`/photos/${photo.id}`}
          state={{ backgroundLocation: location }}
        >
          <img src={photo.thumbnail} alt={photo.alt} />
        </Link>
      ))}
    </div>
  );
}

// Modal component
function PhotoModal() {
  const { id } = useParams();
  const navigate = useNavigate();

  return (
    <div className="modal-overlay" onClick={() => navigate(-1)}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        <img src={`/photos/${id}/full.jpg`} alt="" />
        <button onClick={() => navigate(-1)}>Close</button>
      </div>
    </div>
  );
}

When the user navigates directly to /photos/42 (e.g., from a bookmark), there is no backgroundLocation in state, so PhotoPage renders as a full page instead of a modal.

5.8 Breadcrumbs

Build breadcrumbs using the handle property and useMatches:

// Define breadcrumbs in route configuration
const router = createBrowserRouter([
  {
    id: "root",
    path: "/",
    element: <Root />,
    handle: { breadcrumb: "Home" },
    children: [
      {
        path: "products",
        element: <Products />,
        handle: { breadcrumb: "Products" },
        children: [
          {
            path: ":id",
            element: <ProductDetail />,
            loader: productLoader,
            handle: {
              breadcrumb: (match) => match.data?.product?.name || "Product",
            },
          },
        ],
      },
    ],
  },
]);

// Breadcrumb component
function Breadcrumbs() {
  const matches = useMatches();
  const crumbs = matches.filter((m) => m.handle?.breadcrumb);

  return (
    <nav aria-label="Breadcrumb">
      <ol className="breadcrumbs">
        {crumbs.map((match, index) => {
          const label =
            typeof match.handle.breadcrumb === "function"
              ? match.handle.breadcrumb(match)
              : match.handle.breadcrumb;

          const isLast = index === crumbs.length - 1;

          return (
            <li key={match.id}>
              {isLast ? (
                <span aria-current="page">{label}</span>
              ) : (
                <Link to={match.pathname}>{label}</Link>
              )}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}

5.9 Dynamic Route Parameters

Required Parameters

// Route: /users/:userId
// URL:   /users/123

function UserProfile() {
  const { userId } = useParams();
  // userId === "123" (always a string)
  return <h1>User {userId}</h1>;
}

Optional Parameters (Framework Mode FS Routing)

// File: routes/blog.($slug).tsx
// Matches: /blog (no slug) AND /blog/my-post (with slug)

export default function Blog() {
  const { slug } = useParams();

  if (!slug) {
    return <BlogIndex />;
  }

  return <BlogPost slug={slug} />;
}

Splat (Catch-All) Parameters

// Route: /files/*
// URL:   /files/documents/reports/q4.pdf

function FileViewer() {
  const { "*": filepath } = useParams();
  // filepath === "documents/reports/q4.pdf"

  return <FileDisplay path={filepath} />;
}

Multiple Dynamic Segments

// Route: /org/:orgId/team/:teamId/member/:memberId

function MemberProfile() {
  const { orgId, teamId, memberId } = useParams();
  // All three are available as strings
  return (
    <p>
      Org {orgId}, Team {teamId}, Member {memberId}
    </p>
  );
}

Setting Page Metadata

import type { Route } from "./+types/my-route";

export const meta: Route.MetaFunction = ({ data, params, location }) => {
  return [
    { title: `${data.product.name} | My Store` },
    { name: "description", content: data.product.description },
    { property: "og:title", content: data.product.name },
    { property: "og:image", content: data.product.image },
    { property: "og:url", content: `https://mystore.com${location.pathname}` },
    {
      "script:ld+json": {
        "@context": "https://schema.org",
        "@type": "Product",
        name: data.product.name,
        description: data.product.description,
      },
    },
  ];
};

Meta from child routes replaces parent route meta by default. To merge parent meta:

export const meta: Route.MetaFunction = ({ data, matches }) => {
  // Get parent meta
  const parentMeta = matches.flatMap((match) => match.meta ?? []);
  return [
    ...parentMeta,
    { title: `${data.title} | My App` },
  ];
};
export const links: Route.LinksFunction = () => {
  return [
    { rel: "stylesheet", href: "/styles/product.css" },
    {
      rel: "preload",
      href: "/fonts/Inter.woff2",
      as: "font",
      type: "font/woff2",
      crossOrigin: "anonymous",
    },
    { rel: "canonical", href: "https://mystore.com/products/42" },
    { rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
  ];
};

Custom HTTP Headers

export const headers: Route.HeadersFunction = ({
  loaderHeaders,
  parentHeaders,
  actionHeaders,
  errorHeaders,
}) => {
  return {
    "Cache-Control":
      loaderHeaders.get("Cache-Control") || "public, max-age=300",
    "X-Custom-Header": "value",
  };
};

5.11 Revalidation Control

Fine-tune when loaders re-run after navigations and actions:

export function shouldRevalidate({
  currentUrl,
  currentParams,
  nextUrl,
  nextParams,
  formAction,
  formMethod,
  defaultShouldRevalidate,
  actionResult,
}) {
  // Skip revalidation if only the hash changed
  if (currentUrl.pathname === nextUrl.pathname &&
      currentUrl.search === nextUrl.search) {
    return false;
  }

  // Always revalidate after non-GET submissions
  if (formMethod && formMethod !== "GET") {
    return true;
  }

  // Only revalidate if specific params changed
  if (currentParams.category !== nextParams.category) {
    return true;
  }

  // Fall back to React Router's default behavior
  return defaultShouldRevalidate;
}

5.12 Polling and Real-Time Updates

Polling with useRevalidator

function LiveDashboard() {
  const { data } = useLoaderData();
  const revalidator = useRevalidator();

  useEffect(() => {
    const interval = setInterval(() => {
      if (revalidator.state === "idle") {
        revalidator.revalidate();
      }
    }, 30_000); // Poll every 30 seconds

    return () => clearInterval(interval);
  }, [revalidator]);

  return <DashboardView data={data} />;
}

Revalidate on Focus

function RevalidateOnFocus() {
  const revalidator = useRevalidator();

  useEffect(() => {
    function handleFocus() {
      if (revalidator.state === "idle") {
        revalidator.revalidate();
      }
    }

    window.addEventListener("focus", handleFocus);
    return () => window.removeEventListener("focus", handleFocus);
  }, [revalidator]);

  return null; // Render this component in root layout
}

5.13 Multi-Step Forms (Wizards)

// Use search params to track the current step
function Wizard() {
  const [searchParams, setSearchParams] = useSearchParams();
  const step = Number(searchParams.get("step") || "1");

  return (
    <div>
      <div className="steps">
        <span className={step >= 1 ? "active" : ""}>1. Info</span>
        <span className={step >= 2 ? "active" : ""}>2. Address</span>
        <span className={step >= 3 ? "active" : ""}>3. Review</span>
      </div>

      <Form method="post">
        {step === 1 && <StepInfo />}
        {step === 2 && <StepAddress />}
        {step === 3 && <StepReview />}

        <div className="actions">
          {step > 1 && (
            <button
              type="button"
              onClick={() => setSearchParams({ step: String(step - 1) })}
            >
              Back
            </button>
          )}
          {step < 3 ? (
            <button
              type="button"
              onClick={() => setSearchParams({ step: String(step + 1) })}
            >
              Next
            </button>
          ) : (
            <button type="submit">Submit</button>
          )}
        </div>
      </Form>
    </div>
  );
}

5.14 Testing Routes

Testing with createRoutesStub

React Router provides createRoutesStub for testing routes with their loaders and actions:

import { createRoutesStub } from "react-router";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

test("renders user profile from loader data", async () => {
  const Stub = createRoutesStub([
    {
      path: "/users/:id",
      Component: UserProfile,
      loader: () => ({
        user: { id: "1", name: "Alice", email: "[email removed]" },
      }),
    },
  ]);

  render(<Stub initialEntries={["/users/1"]} />);

  await waitFor(() => {
    expect(screen.getByText("Alice")).toBeInTheDocument();
  });
});

test("handles form submission via action", async () => {
  const actionFn = vi.fn().mockReturnValue({ success: true });

  const Stub = createRoutesStub([
    {
      path: "/contact",
      Component: ContactForm,
      action: actionFn,
    },
  ]);

  render(<Stub initialEntries={["/contact"]} />);

  await userEvent.type(screen.getByLabelText("Email"), "[email removed]");
  await userEvent.click(screen.getByText("Submit"));

  expect(actionFn).toHaveBeenCalled();
});

Testing with MemoryRouter

For simpler component tests that do not need data loading:

import { MemoryRouter, Routes, Route } from "react-router";
import { render, screen } from "@testing-library/react";

test("renders navigation with active state", () => {
  render(
    <MemoryRouter initialEntries={["/dashboard"]}>
      <Routes>
        <Route path="/" element={<Nav />}>
          <Route path="dashboard" element={<Dashboard />} />
        </Route>
      </Routes>
    </MemoryRouter>
  );

  expect(screen.getByText("Dashboard")).toHaveClass("active");
});

Testing Hooks

import { renderHook } from "@testing-library/react";
import { MemoryRouter } from "react-router";

test("useParams returns route parameters", () => {
  const { result } = renderHook(() => useParams(), {
    wrapper: ({ children }) => (
      <MemoryRouter initialEntries={["/users/42"]}>
        <Routes>
          <Route path="/users/:id" element={children} />
        </Routes>
      </MemoryRouter>
    ),
  });

  expect(result.current.id).toBe("42");
});

6. Troubleshooting

6.1 Common Issues

"No routes matched location"

Symptom: A blank page or console warning saying no routes matched.

Causes and fixes:

  1. Missing catch-all route. Add a path="*" route for 404 handling:
<Routes>
  <Route path="/" element={<Home />} />
  <Route path="*" element={<NotFound />} />
</Routes>
  1. Basename mismatch. If your app is served from a subdirectory, configure basename:
// Library mode
<BrowserRouter basename="/my-app">

// Framework mode (react-router.config.ts)
export default { basename: "/my-app" } satisfies Config;
  1. Server not configured for client-side routing. Your web server must return index.html for all paths so the client-side router can handle them. For Nginx:
location / {
  try_files $uri $uri/ /index.html;
}

For Apache, add a .htaccess:

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]

Hydration Mismatches (SSR)

Symptom: React warns about hydration mismatches in the console after server-side rendering.

Common causes:

  • Rendering browser-only values (dates, window properties, Math.random()) during SSR. These produce different output on server vs. client.
  • URL imports with ?url suffix generating different paths between server and client builds (GitHub issue #12775).
  • Browser extensions injecting elements into the DOM.

Fixes:

  • Defer browser-only rendering to useEffect:
function ClientDate() {
  const [date, setDate] = useState<string>();
  useEffect(() => setDate(new Date().toLocaleDateString()), []);
  return <span>{date ?? "Loading..."}</span>;
}
  • Use HydrateFallback in framework mode route modules to show a placeholder until client hydration completes.
  • For ?url imports, use standard imports or inline small assets as data URIs.

Stale Loader Data After Action

Symptom: After a form submission, the page shows outdated data.

By default, React Router revalidates all active loaders after every action. If data is still stale:

  1. Ensure your action returns a value or redirects — returning undefined may skip revalidation.
  2. Verify the loader actually re-fetches data on every call and is not served from a stale HTTP cache. Set appropriate Cache-Control headers.
  3. If using shouldRevalidate, check that it returns true after the relevant action.
  4. Force manual revalidation with useRevalidator().revalidate() if needed.

Lazy Loading Suspense Not Showing

If a Suspense fallback does not display during lazy route transitions (related to GitHub issue #12474):

Lazy route transitions use the useNavigation() state ("loading") rather than React Suspense boundaries. If you want a Suspense-based loading state during initial render, use React's lazy() directly. For data router lazy routes, show loading UI based on navigation.state:

const navigation = useNavigation();
if (navigation.state === "loading") return <Spinner />;

Duplicate Styles After Navigation

Certain versions introduced duplicated CSS when components are suspended across multiple routes (GitHub issue #14754). If you experience duplicated styles:

  • Check if the issue occurs with suspended components shared across routes.
  • Consider colocating styles per route module.
  • Monitor the issue tracker for patches.

"Server-only module referenced by client"

This error occurs when a directory is named exactly .server and its exports are imported in client code, even if unused (GitHub issue #14997).

Fix: Use .server.ts file suffix instead of a .server/ directory, or ensure no client-side code imports from .server directories.

Caution

Never import from .server files or directories in client components, even if the import appears unused. The bundler cannot always tree-shake these imports, and they will fail at runtime in the browser.

Paths with Colons

React Router interprets : as a dynamic parameter prefix. To use a literal colon in a path (GitHub issue #11015):

// This treats ":port" as a parameter — probably not what you want
<Route path="/proxy/:port" />

// Use a splat route and parse the path yourself
<Route path="/proxy/*" element={<Proxy />} />

// In framework mode filesystem routing, use bracket escaping
// File: routes/proxy.[colon]8080.tsx  → matches /proxy:8080

6.2 Build and Dev Server Issues

Vite Plugin Configuration Conflicts

If you have multiple Vite configurations or a monorepo setup that shares flatRoutes(), each instance can leak route state into the other (GitHub issue #15007). Keep each app's Vite config isolated and avoid sharing route resolution functions across config files.

pnpm Trust Policy

When using pnpm, @react-router/dev may require a trust policy exclusion (GitHub issue #14991). Add to your .npmrc:

trustPolicyExclude=@react-router/dev

TypeGen with Wildcard Folder Names

If a route file lives in a folder named *, the typegen command fails (GitHub issue #14744). Rename the folder to avoid the wildcard character.

Dev Server Port Conflicts

# Specify a different port
npx react-router dev --port 3001

# Use strict port (fail instead of auto-incrementing)
npx react-router dev --port 3000 --strictPort

6.3 Performance Optimization

Large Route Trees

For apps with hundreds of routes, enable lazy route discovery to reduce the initial manifest size:

// react-router.config.ts
export default {
  routeDiscovery: { mode: "lazy" },
} satisfies Config;

Only visited routes are loaded. Unvisited routes are fetched on demand when the user hovers or focuses a link.

Controlling Revalidation

Prevent unnecessary loader re-runs with shouldRevalidate:

{
  path: "dashboard",
  loader: dashboardLoader,
  shouldRevalidate: ({ currentUrl, nextUrl, formAction, defaultShouldRevalidate }) => {
    // Only revalidate if action targeted this route or search params changed
    if (formAction?.startsWith("/dashboard")) return true;
    if (currentUrl.search !== nextUrl.search) return true;
    return false;
  },
}

Bundle Size Reduction

  • Framework mode: Enable v8_splitRouteModules to split each route export (loader, component, action) into separate chunks. The client only downloads the component; loaders stay on the server.
  • Data router mode: Use lazy routes to defer loading route modules until navigation.
  • Both modes: Audit imports with your bundler's analysis tools. Avoid importing large libraries in routes that do not need them.

Pre-bundling Dependencies

Enable dependency pre-bundling to speed up cold starts in development:

// react-router.config.ts
export default {
  future: {
    unstable_optimizeDeps: true,
  },
} satisfies Config;

6.4 CI and Testing Stability

If using Cypress or similar E2E frameworks, some teams report increased random CI failures after upgrading (GitHub issue #12974). Mitigations:

  • Increase timeouts for navigation-related assertions.
  • Use cy.intercept to mock data-loading requests and stabilize test behavior.
  • Pin to a specific React Router patch version until flaky tests are resolved.
  • Ensure your test server handles concurrent requests without race conditions.

6.5 Middleware Redirect with useFetcher

When using the v8_middleware future flag, middleware that returns redirect() may not work correctly with useFetcher's fetcher.load() (GitHub issue #14903). As a workaround, handle redirects in route loaders rather than middleware when the route is accessed via fetchers.


7. FAQ

What is the difference between React Router and Remix?

Remix has been merged into React Router. Starting with React Router v7, all Remix features (SSR, loaders, actions, file-based routing, sessions, cookies) are available directly in React Router's framework mode. The @react-router/dev package replaces @remix-run/dev, and react-router replaces @remix-run/react. If you are on Remix v2, the migration path is to upgrade to React Router v7+ with framework mode.

Should I use react-router-dom or react-router?

Import from react-router directly. The react-router-dom package is a backward-compatible re-export shim that adds no functionality. For DOM-specific exports like RouterProvider, import from react-router/dom.

Which mode should I choose?

Situation Recommended Mode
New project (full-stack or SSR) Framework mode
New project (client-side SPA) Framework mode with ssr: false
Adding routing to an existing React app Data Router (library mode)
Very simple routing without data loading Declarative (library mode)
Migrating from React Router v5 Declarative mode first, then upgrade to Data Router
Migrating from Remix v2 Framework mode

Can I mix declarative and data router patterns?

No. Within a single application, use either BrowserRouter + <Routes> (declarative) or createBrowserRouter + RouterProvider (data router). They are separate systems that cannot be combined. Data APIs (loaders, actions, fetchers) are only available with data routers or framework mode.

How do I handle 404 pages?

Declarative mode:

<Routes>
  <Route path="/" element={<Home />} />
  {/* Catch-all — must be last */}
  <Route path="*" element={<NotFound />} />
</Routes>

Data router / framework mode: Use an errorElement on your root route. Any unmatched URL throws a 404 response, which is caught by the nearest error boundary:

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    errorElement: <NotFound />,
    children: [/* ... */],
  },
]);

In framework mode, export an ErrorBoundary from app/root.tsx.

How do I redirect after login?

Save the intended URL in the query string or location state, then navigate to it after successful authentication. See section 5.1 for complete examples.

How do I pass data between routes?

Method Use Case Example
URL params (:id) Resource identifiers /users/42
Search params (?key=val) Filters, pagination, sorting /search?q=react&page=2
Location state Transient UI state (modal backgrounds, return paths) navigate("/x", { state: { from: "/" } })
Outlet context Parent-to-child data <Outlet context={data} />
useRouteLoaderData Access ancestor route's loader data useRouteLoaderData("root")
React context Global state (auth, theme, feature flags) Standard React context

Do forms work without JavaScript?

In framework mode with SSR enabled, <Form> works without JavaScript as a standard HTML form. The browser submits the form, the server runs the action, and returns the re-rendered page. This is progressive enhancement by default.

Important

Progressive enhancement with <Form> only works in framework mode with SSR. In SPA mode or library mode, JavaScript is required for form handling and client-side navigation.

How do I animate route transitions?

Use the browser's View Transitions API via the viewTransition prop on <Link>, <NavLink>, or navigate(). Style transitions with CSS ::view-transition-old and ::view-transition-new pseudo-elements.

For broader browser support, use animation libraries like Framer Motion with useLocation to detect route changes.

What React versions are supported?

React Router supports both React 18 and React 19. React 19 enables additional features like streaming SSR improvements and use of React's built-in use() hook for data loading patterns.

How do I deploy a framework mode app?

  1. Build your app:
npx react-router build
  1. Run the production server with your chosen adapter:
Adapter Command / Setup
@react-router/serve npx react-router-serve ./build/server/index.js
@react-router/express Use createRequestHandler in your Express app
@react-router/cloudflare Deploy with wrangler
@react-router/architect Deploy with Arc

For SPA mode (ssr: false), the build output in build/client/ is static HTML/CSS/JS. Deploy it to any static hosting provider (Netlify, Vercel, S3, GitHub Pages, etc.).

What are the CLI commands?

Command Description
npx react-router build [dir] Build for production
npx react-router dev [dir] Start the development server
npx react-router routes [dir] Display the configured route tree
npx react-router reveal [entry] Generate entry files for customization
npx react-router typegen Generate TypeScript route types

Build flags: --mode (-m), --config (-c), --minify, --sourcemapClient, --sourcemapServer, --profile, --logLevel (-l), --clearScreen, --emptyOutDir, --assetsInlineLimit

Dev flags: --mode (-m), --config (-c), --port, --host, --open, --cors, --force, --strictPort, --profile, --logLevel (-l), --clearScreen

Where can I find architecture and deployment internals?

The Admin Guide covers architecture diagrams, full configuration reference, deployment strategies, security hardening, and internal implementation details. See the Admin Guide at remix-run/react-router.