React Router Framework Mode skill for Claude Code
Loaders, actions and typed routes, the Remix way. Claude builds React Router framework-mode apps the way the router expects: data in loaders, mutations in actions submitted by Form or fetchers, automatic revalidation instead of manual refetching, generated Route types, middleware for shared auth, and an ErrorBoundary per failing route. It checks whether the project is on v7 or v8 first and walks Remix and v7 apps through the future flags safely.
- name
react-router-framework - size229 lines · ≈2,400 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds full-stack apps with React Router in framework mode (v7 and v8, the successor to Remix) — routes.ts configuration, route modules with loader, action, clientLoader, middleware, meta and ErrorBoundary, generated Route types from +types, forms and fetchers with pending UI, streaming with Await, sessions, SSR/SPA/prerender config, and Remix v2 or v7-to-v8 upgrades. Use when editing react-router.config.ts or app/routes.ts, writing loaders and actions, a form does not revalidate data, types from ./+types are missing, or when migrating from Remix or react-router-dom. Try asking
- Add a project edit route with a loader, an action with validation and an error boundary
- Migrate this Remix v2 app to React Router framework mode
- My form submits but the list does not update — fix the loader and action wiring
Use it when
- Editing app/routes.ts, react-router.config.ts or route modules
- Writing loaders, actions, middleware or resource routes
- Types from ./+types are missing or wrong
- Upgrading from Remix v2 or from React Router v7 to v8
Skip it when
- The app uses Next.js — use nextjs-app-router
- You need client-side server-state caching in an SPA — use tanstack-query
How to check the result
Typegen and tsc pass, forms work with JavaScript disabled, loader data refreshes after every action, and thrown 404 and 500 responses render the intended ErrorBoundary.
The common mistake it prevents
Assuming a parent loader runs before a child loader; they run in parallel, so shared auth belongs in middleware.
Framework mode done well means: data loads in route loaders on the server, mutations
go through actions submitted by <Form> or fetchers, React Router revalidates loaders
automatically after each action, types come from generated ./+types/*, and every route
that can fail has an ErrorBoundary.
Step 0 — version and mode
npm ls react-router @react-router/dev @react-router/node react
cat react-router.config.ts app/routes.ts 2>/dev/null
grep -rn "react-router-dom\|@remix-run" package.json app | head
| Situation | Notes |
|---|---|
| v8.x (current major, mid-2026) | ESM-only; Node 22.22+, React 19.2.7+, Vite 7+; react-router-dom package removed (import from react-router, DOM pieces from react-router/dom); middleware always on; splitRouteModules on by default; context is always a RouterContextProvider |
| v7.x | Same route module API; v8 behavior behind future.v8_* flags — enable them all before upgrading |
@remix-run/* imports | Remix v2: migrate to React Router 7 first (it is the next major of Remix) |
createBrowserRouter in main.tsx, no routes.ts | Data/declarative mode, not framework mode — do not add framework files unless asked |
Project files
// react-router.config.ts
import type { Config } from "@react-router/dev/config";
export default {
ssr: true, // false = SPA mode (no server loaders at runtime)
prerender: ["/", "/pricing", "/blog"], // static HTML at build for these paths
} satisfies Config;
// app/routes.ts
import { type RouteConfig, index, layout, prefix, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("login", "routes/login.tsx"),
layout("routes/app-layout.tsx", [
...prefix("projects", [
index("routes/projects.tsx"),
route(":projectId", "routes/project.tsx"),
route(":projectId/edit", "routes/project-edit.tsx"),
]),
]),
route("api/webhooks/stripe", "routes/stripe-webhook.ts"), // resource route: no default export
route("*", "routes/not-found.tsx"),
] satisfies RouteConfig;
File-system routing is opt-in through @react-router/fs-routes (flatRoutes()); follow
whichever the project already uses.
Route module
// app/routes/project.tsx
import { data, Form, isRouteErrorResponse, redirect, useNavigation } from "react-router";
import type { Route } from "./+types/project";
export async function loader({ params, request, context }: Route.LoaderArgs) {
const user = await requireUser(request); // or context.get(userContext)
const project = await db.project.find(params.projectId, user.id);
if (!project) throw data("Project not found", { status: 404 });
return { project };
}
export async function action({ request, params }: Route.ActionArgs) {
const user = await requireUser(request);
const form = await request.formData();
const name = String(form.get("name") ?? "").trim();
if (!name) return data({ errors: { name: "Name is required" } }, { status: 400 });
await db.project.rename(params.projectId, user.id, name);
return redirect(`/projects/${params.projectId}`);
}
export function meta({ loaderData }: Route.MetaArgs) {
return [{ title: loaderData ? `${loaderData.project.name} · Acme` : "Not found" }];
}
export default function Project({ loaderData, actionData }: Route.ComponentProps) {
const busy = useNavigation().state === "submitting";
return (
<Form method="post">
<input name="name" defaultValue={loaderData.project.name} aria-invalid={!!actionData?.errors?.name} />
{actionData?.errors?.name && <p role="alert">{actionData.errors.name}</p>}
<button disabled={busy}>{busy ? "Saving…" : "Save"}</button>
</Form>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (isRouteErrorResponse(error)) return <p>{error.status}: {error.data}</p>;
return <p>Something went wrong.</p>;
}
- Types:
./+types/<file>is generated byreact-router typegen(also bydev/build). Add.react-router/torootDirsin tsconfig and runnpx react-router typegenwhen types are missing. Never hand-write loader types. - Return plain objects; use
data(value, { status, headers })when you need a status or headers.json()/defer()from Remix are gone — return objects and promises directly. throw redirect()/throw data()short-circuits;ErrorBoundaryrenders thrown non-redirect responses.
Which export for which job
| Need | Export |
|---|---|
| Server data for render | loader |
| Form/fetcher mutation on server | action |
| Browser-only data (localStorage, client SDK) or client cache over server data | clientLoader (+ clientLoader.hydrate = true with HydrateFallback) |
| Optimistic client work before server action | clientAction calling serverAction() |
| Auth, logging, context for many routes | middleware on a parent route |
| Cache headers on document/data responses | headers |
| Prevent revalidation after unrelated actions | shouldRevalidate |
| JSON/file endpoints, webhooks | Resource route: loader/action, no default export |
Middleware and context
// app/context.ts
import { createContext } from "react-router";
export const userContext = createContext<User | null>(null);
// app/routes/app-layout.tsx
export const middleware: Route.MiddlewareFunction[] = [
async ({ request, context }) => {
const user = await getUserFromSession(request);
if (!user) throw redirect("/login");
context.set(userContext, user);
},
];
// child loader
const user = context.get(userContext)!;
- On v7 enable
future.v8_middlewarefirst; on v8 it is always on. Custom servers’getLoadContextmust return aRouterContextProviderin v8. - Middleware is a convenience, not the only check: loaders and actions for sensitive data still verify ownership of the specific record.
- Parent loaders and child loaders run in parallel. Do not rely on a parent loader having run before a child loader — that is what middleware is for.
Forms, fetchers, pending UI
| UI | Tool |
|---|---|
| Submit that navigates (create → detail page) | <Form method="post"> |
| In-place mutation, no navigation (like, toggle, inline edit) | const fetcher = useFetcher(); <fetcher.Form> |
| Load data without navigating (combobox) | fetcher.load("/api/search?q=…") to a resource route |
| Global pending indicator | useNavigation().state |
| Optimistic value | Read fetcher.formData while fetcher.state !== "idle" |
Multiple actions in one route: add name="intent" value="delete" to buttons and branch
on form.get("intent") in the action.
Streaming slow data
export async function loader({ params }: Route.LoaderArgs) {
const project = await getProject(params.projectId); // await critical data
const activity = getActivity(params.projectId); // do NOT await: streams
return { project, activity };
}
// <Suspense fallback={<Skeleton/>}><Await resolve={loaderData.activity}>{(a) => <Feed items={a} />}</Await></Suspense>
Streaming needs ssr: true and a server entry that streams (the default does).
Sessions
import { createCookieSessionStorage } from "react-router";
export const sessionStorage = createCookieSessionStorage({
cookie: { name: "__session", httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production",
secrets: [process.env.SESSION_SECRET!], path: "/", maxAge: 60 * 60 * 24 * 30 },
});
Commit with headers: { "Set-Cookie": await sessionStorage.commitSession(session) } on
the redirect or data() response.
Upgrades
- Remix v2 → RR7:
@remix-run/react/node→react-router/@react-router/node,vitePlugin→reactRouter()from@react-router/dev/vite, routes →app/routes.ts(flatRoutes()keeps the old file convention),json/defer→ plain objects. - v7 → v8: on the latest v7, enable every
future.v8_*flag one at a time and fix warnings (7.16+ logs warnings for unset flags); replacereact-router-domimports; raise Node, React and Vite; then bump to v8. Read the official upgrade guide for the exact list.
Gotchas
useLoaderData()still works but the component propsloaderDataare better typed.- Calling your own resource routes from a loader via
fetchadds a hop; import the function instead. - Actions automatically revalidate every active loader. Expensive loaders on unrelated
routes need
shouldRevalidate. clientLoaderwithoutHydrateFallbackon an SSR route renders nothing until hydrated.ssr: false(SPA mode) disallows serverloaders at runtime except in prerendered routes; useclientLoader.- Environment secrets in loaders only; anything exported from a route module that the
client bundle imports must not touch server-only modules — use
*.server.tsfiles.
Verification
npx react-router typegen && npx tsc --noEmit.npm run build && npm run start— exercise each form with JS on and with JS disabled (progressive enhancement: the<Form>must still work).- After each action, the loader data on screen updates without a manual refresh.
- Throw a 404 and a 500 from the loader once and confirm the right
ErrorBoundary.
Report format
**React Router:** 8.x framework mode, SSR + prerender ["/", "/pricing"]
**Routes added:** projects/:projectId (loader, action with intents, ErrorBoundary)
**Middleware:** app-layout requires session, sets userContext
**Verified:** typegen + tsc clean, forms work without JS, 404 boundary renders About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.