react-router-7-framework
npx skills add https://github.com/yonderlab/kota.agent.skills --skill react-router-7-framework
Agent 安装分布
Skill 文档
React Router 7 Framework Mode Best Practices
Version Compatibility
This skill targets React Router 7.9.0+ in framework mode. Key features by version:
| Version | Features |
|---|---|
| v7.0 | Framework mode, type generation, loaders/actions, Route.* types |
| v7.5 | href() utility for type-safe links |
| v7.9+ | Stable middleware and context APIs, v8 future flags |
Future Flags
Enable v8 features in react-router.config.ts:
import type { Config } from "@react-router/dev/config";
export default {
future: {
v8_middleware: true, // Middleware support
v8_splitRouteModules: true, // Route module splitting for performance
},
} satisfies Config;
These will become the default in v8.
Core Principles
- Server-First: Fetch and process data on the server whenever possible. Only use client-side data fetching when absolutely necessary.
- Maximum Type Safety: Always use React Router 7’s generated types (
Route.LoaderArgs,Route.ComponentProps, etc.). Access loader/action data from props or usetypeof loader/typeof actionwith hooks. - Proper Hydration: Understand when to use
clientLoader.hydrate = trueand when to skip it - Declarative Data: Colocate data requirements with routes using loaders
- Progressive Enhancement: Use actions for mutations with automatic revalidation
Route Configuration
Define routes in app/routes.ts using helper functions:
import type { RouteConfig } from "@react-router/dev/routes";
import { route, index, layout, prefix } from "@react-router/dev/routes";
export default [
index("./home.tsx"),
route("about", "./about.tsx"),
layout("./auth-layout.tsx", [
route("login", "./login.tsx"),
route("register", "./register.tsx"),
]),
...prefix("api", [
route("users", "./api/users.tsx"),
]),
route("*", "./not-found.tsx"), // Catch-all 404
] satisfies RouteConfig;
See references/routes-config.md for layout routes, Outlet, splat routes, custom IDs, and nested route patterns.
Type Safety
Generated Types
React Router 7 generates route-specific types in .react-router/types/+types/<route-file>.d.ts for each route. Always import and use these types:
import type { Route } from "./+types/product";
export async function loader({ params }: Route.LoaderArgs) {
// params is typed based on your route definition
const product = await db.getProduct(params.id);
return { product };
}
export default function Product({ loaderData }: Route.ComponentProps) {
// loaderData is inferred from loader return type
return <h1>{loaderData.product.name}</h1>;
}
Available Route Types
Route.LoaderArgs– Types for loader parameters (params, request, context)Route.ActionArgs– Types for action parametersRoute.ClientLoaderArgs– Types for clientLoader parameters (includes serverLoader)Route.ClientActionArgs– Types for clientAction parameters (includes serverAction)Route.ComponentProps– Types for component props (includes loaderData, actionData, matches, etc.)
Accessing Loader/Action Data
In route module default exports, always use props â they provide the best type safety and are the recommended approach in framework mode:
import type { Route } from "./+types/product";
export async function loader() {
return { product: await db.getProduct() };
}
export async function action() {
return { success: true };
}
// â
Props are auto-typed for this specific route
export default function Product({
loaderData,
actionData,
}: Route.ComponentProps) {
return <div>{loaderData.product.name}</div>;
}
When to use hooks instead:
Hooks (useLoaderData, useActionData) are for non-route-module contexts â deep child components, shared UI, or when testing:
// In a child component that doesn't have direct access to route props
import { useLoaderData } from "react-router";
function ProductDetails() {
// Use typeof for type inference
const { product } = useLoaderData<typeof import("./route").loader>();
return <span>{product.description}</span>;
}
Note: Hook generics like
useLoaderData<typeof loader>()exist largely for migration from Remix and are considered secondary to the props pattern. TheRoute.*types via props are the “most type-safe / least foot-gun” approach.
â Never use: useLoaderData<Route.ComponentProps["loaderData"]>() â this pattern is incorrect.
Type-Safe Links
Use the href utility for type-safe route generation (v7.5+):
import { href } from "react-router";
import { Link, NavLink } from "react-router";
// Basic usage with params
<Link to={href("/products/:id", { id: "123" })} />
// Optional params
<NavLink to={href("/:lang?/about", { lang: "en" })} />
// No params needed
<Link to={href("/contact")} />
// Programmatic use
const productLink = href("/products/:id", { id: productId });
navigate(productLink);
// Type errors caught at compile time:
href("/not/a/valid/path"); // â Error: Invalid path
href("/blog/:slug", { oops: 1 }); // â Error: Invalid param name
href("/blog/:slug", {}); // â Error: Missing required param
Benefits:
- Compile-time validation of route paths
- Required params are enforced
- Refactoring routes updates all usages
- IDE autocomplete for available routes
Data Loading Patterns
1. Server-Only Loading (Preferred)
Default pattern – load data on the server, hydrate automatically:
import type { Route } from "./+types/products";
export async function loader({ params, request }: Route.LoaderArgs) {
// Runs on server during SSR and on server during client navigations
const product = await db.getProduct(params.id);
return { product };
}
export default function Product({ loaderData }: Route.ComponentProps) {
return <div>{loaderData.product.name}</div>;
}
When to use: This is the default and preferred pattern. Use unless you have specific client-side requirements.
2. Client-Only Loading
Load data exclusively on the client:
import type { Route } from "./+types/products";
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
// Only runs in the browser
const res = await fetch(`/api/products/${params.id}`);
return await res.json();
}
// Required when clientLoader runs during hydration
export function HydrateFallback() {
return <div>Loading...</div>;
}
export default function Product({ loaderData }: Route.ComponentProps) {
return <div>{loaderData.name}</div>;
}
When to use:
- Accessing browser-only APIs (localStorage, IndexedDB)
- Client-side caching strategies
- No server environment available
Important: clientLoader.hydrate = true is implicit when no server loader exists.
3. Combined Server + Client Loading
Augment server data with client data:
import type { Route } from "./+types/products";
export async function loader({ params }: Route.LoaderArgs) {
// Server data (e.g., from database)
return await db.getProduct(params.id);
}
export async function clientLoader({
params,
serverLoader,
}: Route.ClientLoaderArgs) {
// Get server data + add client data
const [serverData, clientData] = await Promise.all([
serverLoader(),
getClientOnlyData(params.id),
]);
return { ...serverData, ...clientData };
}
clientLoader.hydrate = true as const; // Use 'as const' for proper type inference
export function HydrateFallback() {
return <div>Loading...</div>;
}
export default function Product({ loaderData }: Route.ComponentProps) {
return <div>{loaderData.name}</div>;
}
When to use:
- Combining server data with client-only data (user preferences, client state)
- Augmenting server data with cached data
Important: Set clientLoader.hydrate = true as const to call clientLoader during initial hydration.
4. Skip Server Hop (BFF Pattern)
Load server data on initial request, then call client API directly:
import type { Route } from "./+types/products";
export async function loader({ params }: Route.LoaderArgs) {
// Server loads data on initial document request
const product = await db.getProduct(params.id);
return { product };
}
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
// Subsequent navigations fetch from API directly (skip server hop)
const res = await fetch(`/api/products/${params.id}`);
return await res.json();
}
// clientLoader.hydrate is false (default) - only runs on subsequent navigations
export default function Product({ loaderData }: Route.ComponentProps) {
return <div>{loaderData.product.name}</div>;
}
When to use:
- Backend-For-Frontend pattern
- Initial SSR data load, then direct API calls
- Authentication/cookies work for both server and client
Important: Do NOT set clientLoader.hydrate = true for this pattern. You want clientLoader to skip during hydration.
5. Client-Side Caching
Cache server data on client for subsequent navigations:
import type { Route } from "./+types/products";
let isInitialRequest = true;
const cache = new Map();
export async function loader({ params }: Route.LoaderArgs) {
return await db.getProduct(params.id);
}
export async function clientLoader({
params,
serverLoader,
}: Route.ClientLoaderArgs) {
const cacheKey = `product-${params.id}`;
// First request: prime cache
if (isInitialRequest) {
isInitialRequest = false;
const data = await serverLoader();
cache.set(cacheKey, data);
return data;
}
// Subsequent requests: use cache
const cached = cache.get(cacheKey);
if (cached) return cached;
const data = await serverLoader();
cache.set(cacheKey, data);
return data;
}
clientLoader.hydrate = true as const;
export default function Product({ loaderData }: Route.ComponentProps) {
return <div>{loaderData.name}</div>;
}
When to use:
- Optimizing for repeated visits to same routes
- Reducing server round-trips
- Offline-first strategies
Actions and Mutations
Server Actions (Preferred)
Handle mutations on the server with automatic revalidation:
import type { Route } from "./+types/todos";
import { Form } from "react-router";
export async function loader() {
// This runs after action completes
const todos = await db.getTodos();
return { todos };
}
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const title = formData.get("title");
await db.createTodo({ title });
return { success: true };
}
export default function Todos({ loaderData }: Route.ComponentProps) {
return (
<div>
<ul>
{loaderData.todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
<Form method="post">
<input type="text" name="title" />
<button type="submit">Add Todo</button>
</Form>
</div>
);
}
Key features:
- Automatic revalidation of all loaders after action completes
- Type-safe with
Route.ActionArgs - Works with
<Form>,useFetcher, anduseSubmit
Client Actions
Handle mutations in the browser, optionally calling server action:
import type { Route } from "./+types/todos";
export async function action({ request }: Route.ActionArgs) {
// Server mutation
const formData = await request.formData();
await db.createTodo({ title: formData.get("title") });
return { success: true };
}
export async function clientAction({
request,
serverAction,
}: Route.ClientActionArgs) {
// Invalidate client cache first
clientCache.invalidate();
// Optionally call server action
const result = await serverAction();
return result;
}
export default function Todos({ loaderData }: Route.ComponentProps) {
return <Form method="post">{/* form fields */}</Form>;
}
When to use:
- Need to invalidate client caches before server mutation
- Optimistic UI updates
- Client-side validation before server call
The data() Utility
Use data() to return responses with custom status codes and headers from loaders and actions:
import { data } from "react-router";
import type { Route } from "./+types/item";
// Return with custom status and headers
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const item = await createItem(formData);
return data(item, {
status: 201,
headers: { "X-Custom-Header": "value" },
});
}
// Throw 404 to trigger ErrorBoundary
export async function loader({ params }: Route.LoaderArgs) {
const project = await db.getProject(params.id);
if (!project) {
throw data(null, { status: 404 });
}
return { project };
}
Common status codes:
201– Resource created (after successful POST)400– Bad request (validation errors)404– Not found (missing resource)403– Forbidden (unauthorized access)
Thrown vs returned:
throw data(...)– TriggersErrorBoundary, stops executionreturn data(...)– Returns response, continues rendering
Route Module Exports
Beyond loader, action, and the default component, route modules can export additional functions for metadata, headers, and revalidation control.
meta
Export page metadata (title, description, og tags):
import type { Route } from "./+types/product";
export function meta({ data }: Route.MetaArgs) {
return [
{ title: data.product.name },
{ name: "description", content: data.product.description },
{ property: "og:title", content: data.product.name },
];
}
links
Export link tags (stylesheets, preloads, favicons):
import type { Route } from "./+types/product";
export function links() {
return [
{ rel: "stylesheet", href: "/styles/product.css" },
{ rel: "preload", href: "/fonts/brand.woff2", as: "font", type: "font/woff2" },
];
}
headers
Control HTTP response headers:
import type { Route } from "./+types/product";
export function headers({ loaderHeaders }: Route.HeadersArgs) {
return {
"Cache-Control": loaderHeaders.get("Cache-Control") ?? "max-age=300",
"X-Custom-Header": "value",
};
}
shouldRevalidate
Control when loaders re-run (optimize performance):
import type { Route } from "./+types/products";
export function shouldRevalidate({
currentUrl,
nextUrl,
defaultShouldRevalidate,
}: Route.ShouldRevalidateArgs) {
// Don't revalidate if only search params changed
if (currentUrl.pathname === nextUrl.pathname) {
return false;
}
return defaultShouldRevalidate;
}
Use cases for shouldRevalidate:
- Skip revalidation when navigating within the same route
- Prevent unnecessary refetches after certain actions
- Optimize performance for expensive loaders
ErrorBoundary
Handle errors that occur during loading or rendering:
import { isRouteErrorResponse, useRouteError } from "react-router";
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
);
}
return <div>Something went wrong</div>;
}
Server-Only Modules
Use .server and .client module conventions to prevent accidentally bundling server-only code (secrets, database clients) into the client bundle.
.server Modules
Files ending in .server.ts or in a .server/ directory are never bundled into the client:
app/
âââ utils/
â âââ db.server.ts # Server-only: database client
â âââ auth.server.ts # Server-only: auth logic with secrets
â âââ format.ts # Shared: safe for client and server
âââ .server/
âââ secrets.ts # Server-only: environment secrets
// app/utils/db.server.ts
import { PrismaClient } from "@prisma/client";
// This code never reaches the client bundle
export const db = new PrismaClient();
// app/routes/products.tsx
import { db } from "~/utils/db.server"; // Safe: only used in loader
export async function loader() {
const products = await db.product.findMany();
return { products };
}
.client Modules
Files ending in .client.ts or in a .client/ directory are never bundled into the server:
// app/utils/analytics.client.ts
// Browser-only code (window, document, etc.)
export function trackPageView(path: string) {
window.gtag?.("event", "page_view", { page_path: path });
}
Why This Matters
- Security: Secrets and credentials stay on the server
- Bundle size: Server-only code doesn’t bloat client bundles
- Compatibility: Browser-incompatible code (Node APIs, database clients) won’t break the client build
Rule of thumb: If a module imports secrets, database clients, or Node-only APIs, name it
.server.ts.
Useful Utilities and Hooks
useFetcher
Submit forms and load data without navigation:
import { useFetcher } from "react-router";
function TodoItem({ todo }) {
const fetcher = useFetcher();
const isDeleting = fetcher.state === "submitting";
return (
<div>
<span>{todo.title}</span>
<fetcher.Form method="post" action={`/todos/${todo.id}/delete`}>
<button disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
</button>
</fetcher.Form>
</div>
);
}
States: "idle" | "submitting" | "loading"
Access data: fetcher.data (from loader/action)
Methods: fetcher.submit(), fetcher.load()
useNavigation
Track global navigation state:
import { useNavigation } from "react-router";
function GlobalLoadingIndicator() {
const navigation = useNavigation();
const isNavigating = navigation.state !== "idle";
return isNavigating ? <LoadingSpinner /> : null;
}
States: "idle" | "loading" | "submitting"
useActionData
Access data returned from the most recent action. In route modules, prefer actionData from props (see “Accessing Loader/Action Data” above).
Use useActionData in deep child components:
import { useActionData, Form } from "react-router";
function LoginForm() {
const actionData = useActionData<typeof import("../route").action>();
return (
<Form method="post">
{actionData?.error && <div>{actionData.error}</div>}
<input type="email" name="email" />
<button type="submit">Login</button>
</Form>
);
}
Note: actionData is undefined until an action has been called
useLoaderData
Access the current route’s loader data. In route modules, prefer loaderData from props (see “Accessing Loader/Action Data” above).
Use useLoaderData in deep child components that don’t have direct access to route props:
import { useLoaderData } from "react-router";
// In a child component, not the route module default export
function ProductCard() {
const { products } = useLoaderData<typeof import("../route").loader>();
return <div>{products[0].name}</div>;
}
Important:
useLoaderDataassumes the loader succeeded- Cannot be used in
ErrorBoundaryorLayoutcomponents â useuseRouteLoaderDatafor those cases - Never use
useLoaderData<Route.ComponentProps["loaderData"]>()â this is incorrect
useRouteLoaderData
Access loader data from parent or sibling routes by route ID. Essential for ErrorBoundary and Layout components where useLoaderData is not allowed.
Type-safe pattern with typeof:
import { useRouteLoaderData } from "react-router";
import type { loader as rootLoader } from "./root";
export function Layout({ children }) {
// Type-safe: infers types from root loader
const rootData = useRouteLoaderData<typeof rootLoader>("root");
// Always check for undefined (loader may have thrown)
if (rootData?.user) {
return <div>Welcome, {rootData.user.name}</div>;
}
return <div>Not authenticated</div>;
}
Basic usage (untyped):
import { useRouteLoaderData } from "react-router";
export default function ChildComponent() {
const rootData = useRouteLoaderData("root");
if (rootData?.user) {
return <div>Welcome, {rootData.user.name}</div>;
}
return <div>Not authenticated</div>;
}
When to use:
- Accessing parent route data (e.g., user auth from root loader)
- Sharing data across route hierarchy
- In
ErrorBoundaryorLayoutcomponents whereuseLoaderDatais not allowed
Route IDs: Automatically generated from file paths:
app/root.tsxâ"root"app/routes/products.tsxâ"routes/products"app/routes/products.$id.tsxâ"routes/products.$id"
You can also specify custom IDs in routes.ts:
import { route } from "@react-router/dev/routes";
export default [
route("/products/:id", "./product.tsx", { id: "product-detail" }),
];
useMatches
Access all matched routes and their data/handles:
import { useMatches } from "react-router";
export function Layout({ children }) {
const matches = useMatches();
// Access all matched routes
matches.forEach((match) => {
console.log(match.id); // Route ID
console.log(match.pathname); // URL pathname
console.log(match.params); // URL params
console.log(match.loaderData); // Loader data (may be undefined)
console.log(match.handle); // Custom handle metadata
});
return <div>{children}</div>;
}
Note: Use
match.loaderDatainstead ofmatch.data. Thedataproperty is deprecated.
Common use cases:
- Building breadcrumbs from route hierarchy
- Creating dynamic navigation based on current route
- Accessing metadata from all matched routes
Type safety with UIMatch:
import { useMatches, type UIMatch } from "react-router";
const matches = useMatches();
const rootMatch = matches[0] as UIMatch<{ user: User } | undefined>;
// Guard against undefined loaderData (loader may have thrown)
if (rootMatch.loaderData?.user) {
const { user } = rootMatch.loaderData;
}
Form Component
Use React Router’s Form for enhanced form handling:
import { Form } from "react-router";
<Form method="post" action="/todos">
<input name="title" />
<button type="submit">Create</button>
</Form>
// With navigate={false} to prevent navigation after action
<Form method="post" navigate={false}>
{/* ... */}
</Form>
See references/forms.md for form validation patterns, optimistic UI, and pending states.
useParams
Access route parameters in components:
import { useParams } from "react-router";
function ProductDetail() {
const { productId } = useParams();
return <div>Product: {productId}</div>;
}
Note: In route modules, prefer accessing params from Route.ComponentProps or Route.LoaderArgs.
useRevalidator
Manually trigger data revalidation:
import { useRevalidator } from "react-router";
function RefreshButton() {
const revalidator = useRevalidator();
return (
<button
onClick={() => revalidator.revalidate()}
disabled={revalidator.state === "loading"}
>
{revalidator.state === "loading" ? "Refreshing..." : "Refresh"}
</button>
);
}
Use cases: Polling, window focus refresh, manual refresh buttons.
useNavigate
Programmatic navigation without user interaction:
import { useNavigate } from "react-router";
function LogoutButton() {
const navigate = useNavigate();
const handleLogout = async () => {
await logout();
navigate("/login", { replace: true });
};
return <button onClick={handleLogout}>Logout</button>;
}
See references/navigation.md for navigation options, Outlet, and redirect patterns.
URL Search Params
For filters, pagination, search, and shareable UI state.
Quick example:
import { useSearchParams } from "react-router";
export default function ProductList() {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get("category") || "all";
const handleCategoryChange = (newCategory: string) => {
setSearchParams((prev) => {
prev.set("category", newCategory);
prev.set("page", "1"); // Reset page when filter changes
return prev;
});
};
return (/* ... */);
}
In loaders:
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const category = url.searchParams.get("category") || "all";
const products = await db.getProducts({ category });
return { products, category };
}
See references/search-params.md for pagination patterns, filtering with forms, type-safe parsing, and debounced search.
Route Metadata with handle
Export a handle object to attach custom metadata to routes. This metadata is accessible via useMatches() in ancestor components.
Basic handle Export
// app/routes/products.tsx
import { Link } from "react-router";
export const handle = {
breadcrumb: () => <Link to="/products">Products</Link>,
title: "Products",
icon: "ð¦",
};
Dynamic Breadcrumbs Pattern
Use handle with useMatches to build breadcrumbs:
// app/routes/products.$id.tsx
import type { Route } from "./+types/products.$id";
export async function loader({ params }: Route.LoaderArgs) {
const product = await db.getProduct(params.id);
return { product };
}
export const handle = {
breadcrumb: (match: any) => (
<Link to={`/products/${match.params.id}`}>
{match.loaderData?.product?.name || "Product"}
</Link>
),
};
export default function Product({ loaderData }: Route.ComponentProps) {
return <div>{loaderData.product.name}</div>;
}
Rendering Breadcrumbs in Layout
// app/root.tsx
import { useMatches, Outlet } from "react-router";
export function Layout({ children }) {
const matches = useMatches();
return (
<html>
<body>
<nav>
<ol>
{matches
.filter((match) => match.handle?.breadcrumb)
.map((match, index) => (
<li key={index}>
{match.handle.breadcrumb(match)}
</li>
))}
</ol>
</nav>
{children}
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
Common handle use cases:
- Breadcrumb navigation
- Page titles and metadata
- Icons for navigation items
- Role-based access control metadata
- Analytics tracking data
Middleware (v7.9.0+)
Middleware runs code before/after route handlers for authentication, logging, context sharing.
Quick example:
// app/middleware/auth.ts
import { redirect, createContext } from "react-router";
export const userContext = createContext<User>();
export async function authMiddleware({ request, context }) {
const session = await getSession(request);
if (!session.get("userId")) throw redirect("/login");
const user = await getUserById(session.get("userId"));
context.set(userContext, user);
}
// app/routes/dashboard.tsx
export const middleware = [authMiddleware] satisfies Route.MiddlewareFunction[];
export async function loader({ context }: Route.LoaderArgs) {
const user = context.get(userContext);
return { user };
}
Enable with future.v8_middleware: true in react-router.config.ts.
See references/middleware.md for execution order, error handling, and role-based access patterns.
SSR and Pre-rendering
Configure SSR
Enable server-side rendering in react-router.config.ts:
import type { Config } from "@react-router/dev/config";
export default {
ssr: true,
} satisfies Config;
Configure Pre-rendering
Generate static HTML at build time:
import type { Config } from "@react-router/dev/config";
export default {
ssr: true, // Can be true or false
async prerender() {
return ["/", "/about", "/products", "/contact"];
},
} satisfies Config;
Static-only mode (no runtime server):
export default {
ssr: false,
prerender: true, // Pre-renders all static routes
} satisfies Config;
Async Streaming with Promises
Stream non-critical data while rendering critical data immediately. defer() is deprecated – just return promises directly.
Quick example:
export async function loader() {
const user = await db.getUser(); // Critical - await
const stats = db.getStats(); // Non-critical - don't await
return { user, stats };
}
export default function Dashboard({ loaderData }: Route.ComponentProps) {
const { user, stats } = loaderData;
return (
<div>
<h1>Welcome, {user.name}!</h1>
<Suspense fallback={<StatsSkeleton />}>
<Await resolve={stats}>
{(resolvedStats) => <StatsCard data={resolvedStats} />}
</Await>
</Suspense>
</div>
);
}
Important: Promises must be wrapped in an object (
return { reviews }notreturn reviews).
See references/streaming.md for error handling, useAsyncValue patterns, and when to use streaming.
Common Patterns
Loading States with HydrateFallback
Show loading UI during initial hydration when clientLoader.hydrate = true:
export async function clientLoader({ serverLoader }: Route.ClientLoaderArgs) {
const data = await serverLoader();
return data;
}
clientLoader.hydrate = true as const;
export function HydrateFallback() {
return <div>Loading...</div>;
}
export default function Component({ loaderData }: Route.ComponentProps) {
return <div>{loaderData.content}</div>;
}
Important: HydrateFallback cannot render <Outlet /> as child routes may not be ready.
Error Handling
Use error boundaries for loader/action errors:
import { isRouteErrorResponse, useRouteError } from "react-router";
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
);
}
return <div>Something went wrong!</div>;
}
Pending UI with useNavigation
Show pending states during navigation:
import { useNavigation } from "react-router";
export default function Products({ loaderData }: Route.ComponentProps) {
const navigation = useNavigation();
const isLoading = navigation.state === "loading";
return (
<div className={isLoading ? "opacity-50" : ""}>
{loaderData.products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
Resource Routes (API Endpoints)
Resource routes serve non-UI responses (JSON, PDF, webhooks). A route is a resource route when it exports loader/action but no default component:
// app/routes/api.users.tsx
export async function loader() {
const users = await db.getUsers();
return Response.json(users);
}
export async function action({ request }: Route.ActionArgs) {
const data = await request.json();
const user = await db.createUser(data);
return Response.json(user, { status: 201 });
}
// No default export = resource route
Link with reloadDocument to prevent client-side routing:
<Link reloadDocument to="/api/report.pdf">Download PDF</Link>
See references/resource-routes.md for HTTP method handling, file serving, and webhook patterns.
Decision Tree
Do you need to access client-only APIs (localStorage, browser state)?
ââ YES â Use clientLoader (no server loader)
ââ NO â Continue
Do you need to combine server and client data?
ââ YES â Use loader + clientLoader with hydrate = true
ââ NO â Continue
Do you want to cache data on the client?
ââ YES â Use loader + clientLoader with caching logic + hydrate = true
ââ NO â Continue
Do you want to skip server on subsequent navigations?
ââ YES â Use loader + clientLoader (BFF pattern, no hydrate)
ââ NO â Continue
Do you have slow/non-critical data that blocks rendering?
ââ YES â Return promises without awaiting - wrap with Suspense/Await in component
ââ NO â Use server-only loader (PREFERRED)
Checklist
Before completing any React Router 7 implementation:
- All route modules use
Route.*types from./+types/<route> - Data fetching prioritizes server-side loaders
-
clientLoader.hydrate = true as constis set correctly when needed -
HydrateFallbackis exported whenclientLoader.hydrate = true - Actions use server-side mutations with automatic revalidation
- Forms use
<Form>component from react-router, not native<form> - Type-safe
href()utility is used for route generation - Error boundaries are implemented for route errors
- Loading states use
useNavigationor fetcher states - No client-side data fetching unless absolutely necessary
- Slow/non-critical data returned as promises (not awaited) for streaming
- Critical data is awaited, non-critical data is streamed
-
<Await>wrapped in<Suspense>with fallback UI - Error handling implemented for streaming promises (
errorElementoruseAsyncError) - Search params used for shareable UI state (filters, pagination, search)
- Search params validated and parsed in loaders with proper defaults
- Search param values returned from loader and used as
defaultValuein forms -
<Form method="get">used for filter forms (not POST) - Use
useRouteLoaderDatainstead ofuseLoaderDatain ErrorBoundary/Layout components - Parent route data accessed via
useRouteLoaderData("route-id")with proper undefined checks -
handleexports used for route metadata (breadcrumbs, titles, etc.) - Middleware used for authentication/authorization instead of loader-only patterns
- Context API (
context.set/get) used for sharing data between middleware and loaders -
data()utility used for custom status codes (404, 201, etc.) - Route IDs understood for
useRouteLoaderDatacalls -
meta,links,headersexports used where appropriate -
shouldRevalidateconsidered for performance-critical loaders - Server-only code uses
.server.tsnaming convention - Secrets and database clients never imported in client-accessible modules
- Routes configured in
routes.tswith appropriate helpers (route,index,layout,prefix) - Resource routes (API endpoints) export no default component
- Form validation returns errors with
data({ errors }, { status: 400 }) - Optimistic UI uses
fetcher.formDatafor immediate feedback
Bundled References
- routes-config.md – Route configuration, helpers, Outlet, splats
- navigation.md – Redirects, useNavigate, Outlet context
- forms.md – Validation, optimistic UI, pending states
- resource-routes.md – API endpoints, webhooks, file serving
- middleware.md – Authentication, context API, execution order
- search-params.md – Pagination, filtering, type-safe parsing
- streaming.md – Suspense, Await, deferred data
External References
- React Router Documentation
- Routing Guide
- Type Safety Guide
- Data Loading
- Actions
- Form Validation
- Resource Routes
- Client Data Patterns
- Streaming & Suspense
- Middleware Guide
- Using handle
- Pending UI
Key Notes
- Always prefer server-side data loading over client-side
- In route modules, use props (
Route.ComponentProps) â hooks are for deep child components - Never use
useLoaderData<Route.ComponentProps["loaderData"]>()â this is incorrect - Use
as constwhen settingclientLoader.hydrate = true HydrateFallbackis required whenclientLoader.hydrate = true- Return promises without awaiting to stream slow/non-critical data
- Always wrap
<Await>in<Suspense>with fallback UI useLoaderDatacannot be used inErrorBoundaryorLayoutâ useuseRouteLoaderData- Use
.server.tsnaming for modules containing secrets or database clients - Middleware requires
future.v8_middleware: trueflag (v7.9.0+)