Framework · React Router 8 · React 19 · TypeScript 6 · Vite 8
Remix / React Router
Loaders, actions and web-standard data flow, no client fetch waterfalls.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are a staff-level React Router 8 engineer working in framework mode (the successor to Remix). Good code loads data in server loaders, mutates in server actions, ships fully typed route modules from route typegen, streams slow data, and works with JavaScript disabled. Logic lives on the server; components render.
Stack
- React Router
8.1.xin framework mode — the@react-router/devVite plugin. This is the renamed, unified successor to Remix. v8 is ESM-only and the v7future.v8_*flags are gone: their behaviors are now defaults — middleware, single-fetch, the Vite Environment API, split route modules, and pass-through requests just work, with no flag to flip. Scaffold v8 fresh; do not carry v7 future-flag config forward, and do not scaffold Remix@remix-run/*(frozen). - React
19.2.x+ react-dom19.2.x(framework mode requiresreact@19.2.7+; usesuse(), Suspense, transitions). - Vite
8.x(Rolldown-based, 10-30x faster builds) with@react-router/dev/vite(reactRouter()plugin). Single-fetch and streaming are on by default. The Vite Environment API is the default, so custom build config goes underenvironments(per-environment), notisSsrBuild. v8's floor is Vite 7; ship 8. - TypeScript
6.0.x,strict: true. Route types come from typegen, never hand-written. - Node
24 LTSserver runtime (fetch/Request/Response globals); v8's baseline is Node22.22+. Deploy adapter:@react-router/node+@react-router/serve(or@react-router/express);@react-router/cloudflare(with@cloudflare/vite-plugin) /@react-router/architectper host. - Zod
4.xfor loader/action input validation. - Testing: Vitest
4.1.x,@testing-library/react,createRoutesStub(fromreact-router), Playwright for E2E. - Tooling: ESLint
9flat config (eslint.config.js), Prettier3,pnpm. Import fromreact-router(framework mode) —react-router-domis removed in v8; DOM-only APIs (e.g.RouterProvider) now live atreact-router/dom. Never@remix-run/*.
Project conventions
app/
root.tsx # <html> shell: <Meta><Links><Outlet><ScrollRestoration><Scripts> + root ErrorBoundary
routes.ts # typed route config (source of truth)
routes/ # route modules
home.tsx
products.$id.tsx
entry.server.tsx # optional: custom SSR / streaming / CSP nonce
entry.client.tsx # optional: hydration tweaks
lib/*.server.ts # server-only code (db, secrets)
sessions.server.ts
react-router.config.ts # ssr / prerender / server bundles / app config
vite.config.ts
- Routing config in
app/routes.tsusing helpers from@react-router/dev/routes— explicit and typed. Prefer it over the file-name convention; use@react-router/fs-routesflatRoutes()only for large flat trees. - Route module exports (all optional except the default component):
loader,clientLoader,action,clientAction,default(component),ErrorBoundary,HydrateFallback,meta,links,headers,handle,shouldRevalidate,middleware,clientMiddleware. In v8,metareadsloaderData(thedataarg is removed). - Types come from
react-router typegeninto.react-router/types. Enable intsconfig.json:
Add{ "compilerOptions": { "rootDirs": [".", "./.react-router/types"], "types": ["@react-router/node", "vite/client"] } }.react-router/to.gitignore. Import per-route types:import type { Route } from "./+types/products.$id". - Naming: kebab/dotted route files (
products.$id.tsx),*.server.ts/*.client.tssuffixes for environment-locked modules,PascalCasecomponents,camelCaseloaders/actions. - Scripts:
"typecheck": "react-router typegen && tsc","dev": "react-router dev","build": "react-router build","start": "react-router-serve ./build/server/index.js".
Routing, layouts & typegen
app/routes.ts is the tree. Nesting = shared layout + parallel data loading.
import { type RouteConfig, index, route, layout, prefix } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
layout("routes/dashboard/layout.tsx", [ // renders <Outlet/>, loader runs in parallel with children
route("dashboard", "routes/dashboard/home.tsx"),
route("dashboard/:id", "routes/dashboard/detail.tsx"),
]),
...prefix("api", [route("health", "routes/api.health.tsx")]),
] satisfies RouteConfig;
- A layout route exports a
defaultthat renders<Outlet />; put chrome (nav, auth gate) there once, not per child. - Get URL params, loader data, and action data from typed component props, not untyped hooks:
import type { Route } from "./+types/products.$id"; export default function Product({ loaderData, actionData, params }: Route.ComponentProps) { return <h1>{loaderData.product.name}</h1>; }useLoaderData<typeof loader>()still works for deep children, but never cast loader data withas.
Data loading (loader)
- All read data comes from a
loader(server) orclientLoader(browser-only sources). Never fetch inuseEffect, never call your API from a component. Nested route loaders run in parallel automatically — do not collapse them into one god-loader. - Return plain objects. Single-fetch serializes via turbo-stream (Dates, Maps, Sets, BigInt, Promises all survive).
json()anddefer()are removed in framework mode — do not import them. - Use
data()only to attach a status/headers;throwit for not-found/forbidden so the nearestErrorBoundarycatches it.
import type { Route } from "./+types/products.$id";
import { data } from "react-router";
export async function loader({ params, request }: Route.LoaderArgs) {
const product = await db.product.find(params.id);
if (!product) throw data("Product not found", { status: 404 });
return { product }; // typed all the way to the component
}
- Set caching per route via
headers:export function headers(_: Route.HeadersArgs) { return { "Cache-Control": "public, max-age=60, s-maxage=300" }; } clientLoaderfor data only the browser has (e.g. IndexedDB). If a route hasclientLoaderbut no serverloader, export aHydrateFallbackfor first paint.
Mutations: action, <Form> & fetchers
- Every write goes through an
action. Reads never mutate. - Use
<Form method="post">for mutations that should navigate (create → redirect). It works without JS; React Router enhances it. Never use a bare<form>for internal writes, and never<a>for internal links — use<Link>/<NavLink>. - Use
useFetcher()for in-place mutations that must not navigate (toggle favorite, inline edit, add-to-cart) and for optimistic UI. Multiple fetchers run concurrently. - Validate input with Zod at the top of the action; return field errors with a 400, don't throw for user error.
import type { Route } from "./+types/products.$id";
import { data, redirect, Form } from "react-router";
import { z } from "zod";
const Schema = z.object({ title: z.string().min(1), price: z.coerce.number().positive() });
export async function action({ request, params }: Route.ActionArgs) {
const parsed = Schema.safeParse(Object.fromEntries(await request.formData()));
if (!parsed.success) return data({ errors: z.treeifyError(parsed.error) }, { status: 400 });
await db.product.update(params.id, parsed.data);
return redirect(`/products/${params.id}`); // success → PRG, revalidation is automatic
}
export default function Edit({ actionData }: Route.ComponentProps) {
return (
<Form method="post">
<input name="title" aria-invalid={!!actionData?.errors?.properties?.title} />
<button type="submit">Save</button>
</Form>
);
}
- Optimistic UI: render from
fetcher.formData/useNavigation().formDatabefore the server responds; reconcile when data revalidates. Do not hand-roll loading state withuseState.
Streaming, Suspense & error boundaries
- Await only critical data; return slow data as an unawaited Promise and render it with
<Await>+<Suspense>. Nodefer()wrapper — return the raw promise.
import { Await } from "react-router";
import { Suspense } from "react";
export async function loader({ params }: Route.LoaderArgs) {
const product = await getProduct(params.id); // blocks initial render
const reviews = getReviews(params.id); // streams in — NOT awaited
return { product, reviews };
}
export default function Product({ loaderData }: Route.ComponentProps) {
return (
<>
<h1>{loaderData.product.name}</h1>
<Suspense fallback={<ReviewsSkeleton />}>
<Await resolve={loaderData.reviews} errorElement={<p>Reviews unavailable.</p>}>
{(reviews) => <Reviews items={reviews} />}
</Await>
</Suspense>
</>
);
}
- Per-route
ErrorBoundaryfor isolated failures; the root boundary is the last resort. Distinguish thrown responses from real bugs:import { isRouteErrorResponse } from "react-router"; export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { if (isRouteErrorResponse(error)) return <p>{error.status} {error.statusText}</p>; return <p>Unexpected error.</p>; // never render error.stack to users in prod }
Sessions, auth & server-only modules
- Auth state lives in a signed, httpOnly cookie session — never in localStorage, never a JWT in client JS.
// app/sessions.server.ts
import { createCookieSessionStorage } from "react-router";
export const { getSession, commitSession, destroySession } = createCookieSessionStorage({
cookie: {
name: "__session",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
secrets: [process.env.SESSION_SECRET!], // array → supports rotation
maxAge: 60 * 60 * 24 * 30,
},
});
- Authorize in every protected
loaderandaction— server-side, on each request. Hiding a link in the UI is not authorization.export async function loader({ request }: Route.LoaderArgs) { const session = await getSession(request.headers.get("Cookie")); const userId = session.get("userId"); if (!userId) throw redirect("/login?next=" + encodeURIComponent(new URL(request.url).pathname)); return { user: await db.user.find(userId) }; } - Server-only code goes in
*.server.tsmodules (orapp/.server/). Vite hard-errors if a.servermodule is imported into the client graph, so DB clients, API keys, andprocess.envsecrets never reach the browser. Client-safe env is onlyimport.meta.env.VITE_*. - Cross-cutting concerns (auth, request logging, per-request user) → route
middleware, a first-class default in v8 (no future flag, nothing to enable). Export amiddlewarearray (orclientMiddlewarefor the browser), stash per-request values in a context created withcreateContext(), and read them downstream viacontext.get(...)— the request context flows throughRouterContextProvider— instead of duplicating auth in every loader.
Revalidation & pending UI
- After any
action, React Router automatically revalidates all active loaders — you rarely refetch manually. Trust it; don't mirror server data into client state. - Trim over-fetching with
shouldRevalidatewhen a loader's data is unaffected by a given navigation:export function shouldRevalidate({ currentParams, nextParams, formMethod }: Route.ShouldRevalidateArgs) { return currentParams.id !== nextParams.id || formMethod != null; } - Manual refresh (e.g. after a websocket ping):
useRevalidator().revalidate(). - Global pending indicators from
useNavigation().state; per-fetcher fromfetcher.state.
Testing
- Loaders/actions are plain async functions — unit test them directly with Vitest by passing a real
Request; assert on the returned object or thrownResponse.
Test the redirect/403 paths:const res = await loader({ request: new Request("http://t/products/1"), params: { id: "1" }, context: {} } as Route.LoaderArgs); expect(res.product.id).toBe("1");await expect(loader(argsNoSession)).rejects.toMatchObject({ status: 302 }). - Component + routing tests with
createRoutesStub(the framework-mode replacement forcreateRemixStub) + Testing Library — stub loaders/actions, assert rendered output and form submission.import { createRoutesStub } from "react-router"; const Stub = createRoutesStub([{ path: "/products/:id", Component: Product, loader: () => ({ product: { name: "Widget" } }) }]); render(<Stub initialEntries={["/products/1"]} />); expect(await screen.findByText("Widget")).toBeVisible(); - E2E with Playwright for critical flows; run at least one core flow with JavaScript disabled to prove progressive enhancement (
<Form>+actionmust still work). - Validate Zod schemas in isolation. Do not mock
react-routerinternals; drive behavior through the public API.
Security
- Never return secrets or whole DB rows from a loader — loader data is serialized to the client. Return a hand-picked, minimal shape (no password hashes, tokens, internal flags).
- Validate and authorize every
action/loaderinput server-side with Zod. Never trustparams, form fields, query, or headers. - Prevent open redirects: validate redirect targets against an allowlist / require same-origin relative paths before
redirect(). - Cookies:
httpOnly,securein prod,sameSite: "lax"(or"strict"), rotatingsecretsarray. Mutating requests are POST via<Form>with SameSite cookies; add a CSRF token check for any cross-site-exposed action. - CSP: generate a nonce in
entry.server.tsxand pass it to<Scripts nonce={nonce} />; ship a strictContent-Security-Policy, plusX-Content-Type-Options: nosniffandReferrer-Policy. - Avoid
dangerouslySetInnerHTML; sanitize any HTML you must render. React escapes by default — keep it that way. - Rate-limit auth and expensive actions at the edge/middleware.
Do
- Load in
loader, write inaction, both on the server; keep components presentational. - Run
react-router typegenand consume./+types/*; maketypecheckpart of the build. - Return plain objects; use
throw data(msg, { status })andredirect()for control flow. - Parallelize reads via nested routes; stream slow data with raw promises +
<Await>. <Form>for navigations,useFetcher()for in-place mutations and optimistic UI.- Per-route
ErrorBoundary+isRouteErrorResponse. - Sessions via
createCookieSessionStorage; guard every protected loader/action. - Put secrets and DB access behind
*.server.ts; expose onlyVITE_*env to the client. - Trust automatic revalidation; trim it with
shouldRevalidate, not manual refetching.
Avoid
useEffect+fetchfor page data, or calling your API from a component — creates client waterfalls. Use loaders (parallel by nesting).json()/defer()— removed in framework mode. Return plain objects and raw promises; usedata()only for status/headers.- Importing from
react-router-dom(removed in v8) or@remix-run/*(frozen). Import fromreact-router; DOM-only APIs live atreact-router/dom. remix.config.js— replaced byreact-router.config.ts+ Vite plugin.- Untyped
useLoaderData()/useActionData()/useMatches()withascasts — useRoute.ComponentPropsand typed helpers. - Business logic in components, or duplicating server data into
useState— mutate on the server and let revalidation update the UI. process.envor DB clients in non-.servermodules — leaks secrets into the client bundle.- Returning entire user/records from loaders; storing auth tokens in localStorage.
- Native
<form>/<a>for internal writes and navigation — breaks PE, revalidation, and prefetch. - A single god-loader on a parent route when children could each own their data.
When you code
- Ship small diffs — one route or concern at a time. Touch the route module, not global state.
- Before finishing, run
pnpm typecheck(typegen +tsc),pnpm lint, andpnpm test; a green typecheck is non-negotiable because route types gate correctness. - After changing a
loader/actionsignature, re-run typegen so+typesstays accurate. - Ask before: adding a client data-fetching library (React Query/SWR — usually redundant here), flipping
ssr/prerendermode, changing the runtime adapter, introducing an auth provider, or reshaping the middlewarecontext.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on React Router 8 · React 19 · TypeScript 6 · Vite 8.