React Router Patterns
Quick Guide: React Router v7 has three modes: Declarative (
<BrowserRouter>), Data (createBrowserRouter), and Framework (Vite plugin). This skill covers Data Mode — the sweet spot for SPAs needing loaders, actions, and pending states without a full framework. All imports come from"react-router"(thereact-router-dompackage is removed).defer()andjson()are removed in v7 — return plain objects from loaders. Form method values are now uppercase ("POST", not"post").
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST use createBrowserRouter + <RouterProvider> for Data Mode — NEVER use <BrowserRouter> with <Routes> if you need loaders, actions, or fetchers)
(You MUST import from "react-router" — the react-router-dom package is removed in v7. All exports, including RouterProvider and createBrowserRouter, come from "react-router")
(You MUST return plain objects from loaders — json() and defer() are removed in v7. Return { data } directly or use Response.json())
(You MUST use throw redirect() in loaders and shared helpers to short-circuit execution — return redirect() also works but does not stop execution in helper function call stacks)
(You MUST use errorElement or ErrorBoundary on routes — unhandled loader/action errors crash the entire router)
</critical_requirements>
Auto-detection: React Router, createBrowserRouter, RouterProvider, useLoaderData, useActionData, useNavigation, useSearchParams, useFetcher, useRouteError, useParams, useNavigate, Outlet, NavLink, Form, loader, action, errorElement, ErrorBoundary, redirect, isRouteErrorResponse, route.lazy, useOutletContext, shouldRevalidate, useRevalidator
When to use:
- Building React SPAs that need data loading, form actions, or pending states
- Apps requiring nested layouts with shared UI (sidebars, headers)
- Route-level error boundaries and not-found handling
- URL search param state management
- Code splitting at the route level
- Non-navigating mutations (fetchers for inline forms, buttons)
Key patterns covered:
- Data Mode setup with
createBrowserRouterandRouterProvider - Route loaders and actions for data fetching and mutations
- Nested layouts with
<Outlet />anduseOutletContext - Type-safe navigation with
Link,NavLink,useNavigate,redirect - Error handling with
errorElement,useRouteError,isRouteErrorResponse - Search params with
useSearchParams - Non-navigating mutations with
useFetcher - Code splitting with
route.lazy - Navigation state with
useNavigation - Protected routes and auth guard patterns
When NOT to use:
- Simple apps with 1-2 pages and no data loading (Declarative Mode with
<BrowserRouter>is sufficient) - Full-stack SSR apps (use Framework Mode or an SSR framework instead)
- Static sites without client-side navigation
Examples
- Core Setup & Route Config -- createBrowserRouter, RouterProvider, route objects, basic loaders
- Data Loading & Actions -- loaders, actions, Form, useFetcher, revalidation
- Navigation & Search Params -- Link, NavLink, useNavigate, redirect, useSearchParams
- Error Handling & Code Splitting -- errorElement, useRouteError, route.lazy, pending UI
- Layouts & Auth Guards -- Outlet, useOutletContext, protected routes, nested layouts
For quick API reference (hooks, components, route options), see reference.md.
<philosophy>
Philosophy
React Router v7 treats the router as a data layer, not just a URL matcher. Routes define what data to load (loader), what mutations to handle (action), and what errors to catch (errorElement) — all before the component renders. This moves data orchestration out of components and into the route tree, eliminating loading waterfalls and duplicated error handling.
Core principles:
- Routes own their data — Loaders fetch before render, actions handle mutations. Components receive data, they do not fetch it.
- URL is the source of truth — Search params, path params, and navigation state all live in the URL. No hidden state.
- Errors bubble up — Like React error boundaries,
errorElementcatches errors at the nearest route. Unhandled errors bubble to the parent. - Revalidation is automatic — After a successful action, all active loaders re-run. No manual cache invalidation needed. (In v7, loaders skip revalidation after action errors unless
shouldRevalidateopts in.) - Fetchers are for mutations without navigation —
useFetcherhandles inline forms, buttons, and background saves without changing the URL.
When to use Data Mode:
- SPAs with data loading needs and client-side routing
- Apps where you want route-level loaders/actions but control your own bundling
- Projects not ready for Framework Mode but outgrowing Declarative Mode
When NOT to use:
- If you only need URL matching and
<Link>— Declarative Mode is simpler - If you want SSR, streaming, or file-based routing — Framework Mode or an SSR framework is better
- If your app has no data loading — the overhead of Data Mode is not justified
<patterns>
Core Patterns
Pattern 1: Data Mode Setup
Define routes as objects with createBrowserRouter. Pass the router to <RouterProvider>. This is the entry point for all Data Mode features.
import { createBrowserRouter, RouterProvider } from "react-router";
import { RootLayout } from "./layouts/root-layout";
import { HomePage } from "./pages/home";
import { PostsPage, postsLoader } from "./pages/posts";
const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
errorElement: <RootError />,
children: [
{ index: true, element: <HomePage /> },
{
path: "posts",
element: <PostsPage />,
loader: postsLoader,
},
],
},
]);
function App() {
return <RouterProvider router={router} />;
}
Why: Route config lives outside React rendering, enabling the router to call loaders before components mount. errorElement at the root catches any unhandled error in the tree.
See examples/core.md for complete setup with nested routes and error handling.
Pattern 2: Loaders and Actions
Loaders fetch data before the route renders. Actions handle form mutations. Both receive { request, params }. After an action completes, all active loaders automatically revalidate.
// Loader: runs before render
export async function postsLoader() {
const response = await fetch("/api/posts");
if (!response.ok) throw new Response("Failed to load", { status: 500 });
return response.json(); // or return { posts: await response.json() }
}
// Action: handles Form submissions
export async function createPostAction({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const title = formData.get("title") as string;
const post = await createPost({ title });
return redirect(`/posts/${post.id}`);
}
// Route config
{
path: "posts",
element: <PostsPage />,
loader: postsLoader,
action: createPostAction,
}
Why: Loaders run in parallel for sibling routes — no waterfall. Actions revalidate all active loaders automatically, keeping the UI in sync. Throwing a Response from a loader triggers errorElement.
See examples/data-loading.md for actions, useFetcher, and revalidation patterns.
Pattern 3: Error Boundaries with errorElement
Every route can define errorElement to catch errors from its loader, action, or component. Use useRouteError() to access the error and `isRou