Loading...
Loading...
Apply React Router 7 framework mode best practices including server-first data fetching, type-safe loaders/actions, proper hydration strategies, middleware authentication, handle metadata, useMatches/useRouteLoaderData hooks, and maximum type safety. Use when working with React Router 7 framework mode, implementing loaders, actions, route protection, breadcrumbs, streaming with Suspense/Await, URL search params, form validation, optimistic UI, resource routes (API endpoints), route configuration, or building SSR applications.
npx skill4agent add yonderlab/kota.agent.skills react-router-7-framework| Version | Features |
|---|---|
| v7.0 | Framework mode, type generation, loaders/actions, |
| v7.5 | |
| v7.9+ | Stable middleware and context APIs, v8 future flags |
react-router.config.tsimport 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;Route.LoaderArgsRoute.ComponentPropstypeof loadertypeof actionclientLoader.hydrate = trueapp/routes.tsimport 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;.react-router/types/+types/<route-file>.d.tsimport 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>;
}Route.LoaderArgsRoute.ActionArgsRoute.ClientLoaderArgsRoute.ClientActionArgsRoute.ComponentPropsimport 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>;
}useLoaderDatauseActionData// 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 likeexist largely for migration from Remix and are considered secondary to the props pattern. TheuseLoaderData<typeof loader>()types via props are the "most type-safe / least foot-gun" approach.Route.*
useLoaderData<Route.ComponentProps["loaderData"]>()hrefimport { 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 paramimport 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>;
}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>;
}clientLoader.hydrate = trueloaderimport 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>;
}clientLoader.hydrate = true as constimport 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>;
}clientLoader.hydrate = trueimport 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>;
}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>
);
}Route.ActionArgs<Form>useFetcheruseSubmitimport 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>;
}data()data()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 };
}201400404403throw data(...)ErrorBoundaryreturn data(...)loaderactionimport 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 },
];
}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" },
];
}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",
};
}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;
}shouldRevalidateimport { 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.client.server.ts.server/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.ts.client/// app/utils/analytics.client.ts
// Browser-only code (window, document, etc.)
export function trackPageView(path: string) {
window.gtag?.("event", "page_view", { page_path: path });
}Rule of thumb: If a module imports secrets, database clients, or Node-only APIs, name it..server.ts
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>
);
}"idle" | "submitting" | "loading"fetcher.datafetcher.submit()fetcher.load()import { useNavigation } from "react-router";
function GlobalLoadingIndicator() {
const navigation = useNavigation();
const isNavigating = navigation.state !== "idle";
return isNavigating ? <LoadingSpinner /> : null;
}"idle" | "loading" | "submitting"actionDatauseActionDataimport { 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>
);
}actionDataloaderDatauseLoaderDataimport { 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>;
}useLoaderDataErrorBoundaryLayoutuseRouteLoaderDatauseLoaderData<Route.ComponentProps["loaderData"]>()ErrorBoundaryLayoutuseLoaderDatatypeofimport { 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>;
}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>;
}ErrorBoundaryLayoutuseLoaderDataapp/root.tsx"root"app/routes/products.tsx"routes/products"app/routes/products.$id.tsx"routes/products.$id"routes.tsimport { route } from "@react-router/dev/routes";
export default [
route("/products/:id", "./product.tsx", { id: "product-detail" }),
];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: Useinstead ofmatch.loaderData. Thematch.dataproperty is deprecated.data
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;
}Formimport { 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>import { useParams } from "react-router";
function ProductDetail() {
const { productId } = useParams();
return <div>Product: {productId}</div>;
}Route.ComponentPropsRoute.LoaderArgsimport { useRevalidator } from "react-router";
function RefreshButton() {
const revalidator = useRevalidator();
return (
<button
onClick={() => revalidator.revalidate()}
disabled={revalidator.state === "loading"}
>
{revalidator.state === "loading" ? "Refreshing..." : "Refresh"}
</button>
);
}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>;
}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 (/* ... */);
}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 };
}handleuseMatches()// app/routes/products.tsx
import { Link } from "react-router";
export const handle = {
breadcrumb: () => <Link to="/products">Products</Link>,
title: "Products",
icon: "📦",
};handleuseMatches// 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>;
}// 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 />;
}// 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 };
}future.v8_middleware: truereact-router.config.tsreact-router.config.tsimport type { Config } from "@react-router/dev/config";
export default {
ssr: true,
} satisfies Config;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;export default {
ssr: false,
prerender: true, // Pre-renders all static routes
} satisfies Config;defer()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 (notreturn { reviews }).return reviews
clientLoader.hydrate = trueexport 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>;
}HydrateFallback<Outlet />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>;
}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>
);
}loaderaction// 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 routereloadDocument<Link reloadDocument to="/api/report.pdf">Download PDF</Link>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)Route.*./+types/<route>clientLoader.hydrate = true as constHydrateFallbackclientLoader.hydrate = true<Form><form>href()useNavigation<Await><Suspense>errorElementuseAsyncErrordefaultValue<Form method="get">useRouteLoaderDatauseLoaderDatauseRouteLoaderData("route-id")handlecontext.set/getdata()useRouteLoaderDatametalinksheadersshouldRevalidate.server.tsroutes.tsrouteindexlayoutprefixdata({ errors }, { status: 400 })fetcher.formDataRoute.ComponentPropsuseLoaderData<Route.ComponentProps["loaderData"]>()as constclientLoader.hydrate = trueHydrateFallbackclientLoader.hydrate = true<Await><Suspense>useLoaderDataErrorBoundaryLayoutuseRouteLoaderData.server.tsfuture.v8_middleware: true