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.

  • namereact-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

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.

~/.claude/skills/react-router-framework/SKILL.md

--- name: react-router-framework description: Builds full-stack apps with React Router in framework mode (v7 and v8, the successor to Remix) — routes.ts configuration, route modules… license: MIT ---

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
SituationNotes
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.xSame route module API; v8 behavior behind future.v8_* flags — enable them all before upgrading
@remix-run/* importsRemix v2: migrate to React Router 7 first (it is the next major of Remix)
createBrowserRouter in main.tsx, no routes.tsData/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 by react-router typegen (also by dev/build). Add .react-router/ to rootDirs in tsconfig and run npx react-router typegen when 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; ErrorBoundary renders thrown non-redirect responses.

Which export for which job

NeedExport
Server data for renderloader
Form/fetcher mutation on serveraction
Browser-only data (localStorage, client SDK) or client cache over server dataclientLoader (+ clientLoader.hydrate = true with HydrateFallback)
Optimistic client work before server actionclientAction calling serverAction()
Auth, logging, context for many routesmiddleware on a parent route
Cache headers on document/data responsesheaders
Prevent revalidation after unrelated actionsshouldRevalidate
JSON/file endpoints, webhooksResource 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_middleware first; on v8 it is always on. Custom servers’ getLoadContext must return a RouterContextProvider in 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

UITool
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 indicatoruseNavigation().state
Optimistic valueRead 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); replace react-router-dom imports; 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 props loaderData are better typed.
  • Calling your own resource routes from a loader via fetch adds a hop; import the function instead.
  • Actions automatically revalidate every active loader. Expensive loaders on unrelated routes need shouldRevalidate.
  • clientLoader without HydrateFallback on an SSR route renders nothing until hydrated.
  • ssr: false (SPA mode) disallows server loaders at runtime except in prerendered routes; use clientLoader.
  • Environment secrets in loaders only; anything exported from a route module that the client bundle imports must not touch server-only modules — use *.server.ts files.

Verification

  1. npx react-router typegen && npx tsc --noEmit.
  2. npm run build && npm run start — exercise each form with JS on and with JS disabled (progressive enhancement: the <Form> must still work).
  3. After each action, the loader data on screen updates without a manual refresh.
  4. 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.

esc