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-dompackage still exists but is a thin re-export shim. Import everything directly fromreact-router. The DOM-specific exports (likeRouterProviderfor the browser) are available fromreact-router/domwhen 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>© 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
createBrowserRouteris for browser environments. For hash-based routing usecreateHashRouter, and for testing usecreateMemoryRouter. 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
Link
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>
NavLink
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>
Navigate
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
useBeforeUnloadonly handles browser tab close/reload. For in-app navigation blocking, useuseBlockerinstead. 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_middlewarefuture flag and its API may change before the v8 stable release. Enable it withfuture: { v8_middleware: true }inreact-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>
);
}
5.10 Meta and Links (Framework Mode)
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` },
];
};
Adding Link Tags
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:
- Missing catch-all route. Add a
path="*"route for 404 handling:
<Routes>
<Route path="/" element={<Home />} />
<Route path="*" element={<NotFound />} />
</Routes>
- 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;
- Server not configured for client-side routing. Your web server must return
index.htmlfor 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,
windowproperties,Math.random()) during SSR. These produce different output on server vs. client. - URL imports with
?urlsuffix 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
HydrateFallbackin framework mode route modules to show a placeholder until client hydration completes. - For
?urlimports, 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:
- Ensure your action returns a value or redirects — returning
undefinedmay skip revalidation. - Verify the loader actually re-fetches data on every call and is not served from a stale HTTP cache. Set appropriate
Cache-Controlheaders. - If using
shouldRevalidate, check that it returnstrueafter the relevant action. - 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
.serverfiles 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_splitRouteModulesto 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
lazyroutes 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.interceptto 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?
- Build your app:
npx react-router build
- 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.