React Router v7 skill for Codex
Loaders, actions and fetchers the framework way. Codex declares routes in app/routes.ts, writes route modules with loader, action, meta and ErrorBoundary using generated Route types, and picks between Form, useFetcher and clientLoader per interaction. It controls loader revalidation deliberately, keeps server-only code out of client bundles, and follows an ordered path for moving Remix v2 or React Router v6 apps onto v7.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- react-router-v7 Installs to ~/.agents/skills/react-router-v7/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install react-router-v7 The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project react-router-v7 Installs to .agents/skills/react-router-v7/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/react-router-v7.zipunzip -o react-router-v7.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) react-router-v7 PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $react-router-v7, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds React Router v7 framework-mode apps (the Remix successor) — routes.ts config, route modules with loader, action, clientLoader, meta and ErrorBoundary, generated Route types, fetchers, pending UI, revalidation, SSR or SPA or prerender modes — and migrates Remix v2 or React Router v6 apps to it. Use when adding a route or data loading to a React Router v7 app, wiring forms with actions, upgrading from @remix-run packages, or debugging loaders that re-run, missing types, or hydration issues.
Use it when
- Adding routes, loaders or actions in a React Router v7 app
- Upgrading from Remix v2 or React Router v6
- Building inline mutations with fetchers and optimistic UI
- Loaders re-run too often or types are missing from +types imports
Use something else when
- The app uses TanStack Router with search-param validation: use tanstack-router
- The project is a Next.js App Router codebase: use nextjs-app-router
How to check the result
Typegen and tsc pass, the production build serves each touched route on direct load and client navigation, and forms submit with JavaScript disabled.
The mistake it prevents
Putting the auth check only in a parent layout loader, while child loaders run in parallel and return data to anyone who requests them.
Done well means: routes are declared in one config, each route module owns its data
(loader), its writes (action), its head tags (meta) and its failure UI (ErrorBoundary);
types come from react-router typegen rather than hand-written generics; and mutations
use <Form> or fetchers so React Router revalidates loaders automatically.
Before you start
- Read
AGENTS.md,package.jsonandreact-router.config.ts. - Identify the mode:
- Framework mode:
@react-router/devVite plugin,app/routes.ts, route modules. This skill targets it. - Data mode:
createBrowserRouterwith route objects and loaders, no Vite plugin. - Declarative mode:
<BrowserRouter>and<Routes>only, no loaders.
- Framework mode:
- Check
ssrandprerenderinreact-router.config.ts:ssr: true(server rendering),ssr: false(SPA mode, onlyclientLoader/clientActionrun at runtime),prerenderfor static paths built at build time. - Check the version for opt-in flags under
future(for examplev8_middlewarefor route middleware). Do not enable future flags as a drive-by change.
Route 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/index.tsx"),
route(":projectId", "routes/projects/detail.tsx"),
route(":projectId/settings", "routes/projects/settings.tsx"),
]),
]),
route("api/health", "routes/api.health.ts"), // resource route: no default export
route("*", "routes/not-found.tsx"),
] satisfies RouteConfig;
File-system routing is optional via flatRoutes() from @react-router/fs-routes.
Prefer the explicit config in apps where route structure is reviewed in PRs.
Route module
// app/routes/projects/detail.tsx
import { data, Form, isRouteErrorResponse, redirect, useNavigation } from "react-router";
import type { Route } from "./+types/detail";
export async function loader({ params, request }: Route.LoaderArgs) {
const user = await requireUser(request);
const project = await getProject(params.projectId, user.orgId);
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: "Required" } }, { status: 400 });
await renameProject(params.projectId, user.orgId, name);
return redirect(`/projects/${params.projectId}`);
}
export function meta({ data }: Route.MetaArgs) {
return [{ title: data ? `${data.project.name} | Acme` : "Not found | Acme" }];
}
export default function ProjectDetail({ loaderData, actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const saving = navigation.state === "submitting";
return (
<Form method="post">
<input name="name" defaultValue={loaderData.project.name} />
{actionData?.errors?.name && <p role="alert">{actionData.errors.name}</p>}
<button disabled={saving}>{saving ? "Saving..." : "Save"}</button>
</Form>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (isRouteErrorResponse(error)) return <h1>{error.status} {error.data}</h1>;
return <h1>Unexpected error</h1>;
}
Rules:
- Import route types from
./+types/<module-name>. They are generated by the Vite plugin in dev and byreact-router typegen; add.react-router/to.gitignoreandrootDirs: [".", "./.react-router/types"]intsconfig.jsonif the template lacks it. - Return plain objects from loaders.
json()anddefer()from Remix are gone: return promises inside the object to stream them, and wrap withdata()only when you need a status or headers. - Throw
data(...)orredirect(...)for control flow; thrown responses go to the ErrorBoundary or redirect, returned ones go to the component. - Authorization goes in every loader and action. A parent layout loader does not protect child loaders; they run in parallel.
Choosing the data API
| Need | Use |
|---|---|
| Data needed to render a route | loader (server) |
| Browser-only data or SPA mode | clientLoader; add HydrateFallback when it must run on first load |
| Navigation after a write | <Form method="post"> + action returning redirect() |
| Write without navigating (like, toggle, inline edit) | useFetcher() with fetcher.Form or fetcher.submit |
| Load data for a combobox or popover | fetcher.load("/resource-route?q=...") against a resource route |
| Optimistic UI | read fetcher.formData / navigation.formData while pending |
| Expensive loader should not rerun after unrelated actions | shouldRevalidate export |
| Streaming slow data | return the promise in loader data and render with <Await> in <Suspense> (or use()) |
function FavoriteButton({ project }: { project: Project }) {
const fetcher = useFetcher();
const optimistic = fetcher.formData ? fetcher.formData.get("favorite") === "true" : project.favorite;
return (
<fetcher.Form method="post" action={`/projects/${project.id}/favorite`}>
<button name="favorite" value={String(!optimistic)}>{optimistic ? "Unstar" : "Star"}</button>
</fetcher.Form>
);
}
Revalidation behavior
After any action completes, React Router re-runs the loaders of all matched routes. That
keeps data fresh without cache keys, but expensive loaders pay for it. Use
shouldRevalidate({ formAction, defaultShouldRevalidate }) to skip, and return
defaultShouldRevalidate in the default branch so you do not freeze data permanently.
Migrating from Remix v2
- Enable all Remix v2
futureflags and fix warnings first; ship that. - Run the community codemod or replace imports by hand:
@remix-run/react,@remix-run/nodeand friends becomereact-router(runtime adapters move to@react-router/node,@react-router/serve,@react-router/cloudflare). - Swap the Vite plugin to
reactRouter()from@react-router/dev/vite, addreact-router.config.ts, and createapp/routes.ts(useflatRoutes()to keep the existing file naming). - Replace
json()anddefer()returns with plain objects; replaceLoaderFunctionArgsstyle types with generatedRoute.*types. - Update scripts:
react-router dev,react-router build, andreact-router-serve ./build/server/index.js(or the custom server).
Package installs and codemods need network access; ask for approval when the sandbox blocks it.
From React Router v6 (declarative or data mode): upgrade to the latest v6 with all
future flags on, then bump to v7 and change imports from react-router-dom to
react-router. Framework mode is a separate, optional step.
Gotchas
loaderDatainmetacan be undefined when the loader threw; guard it.clientLoaderwithoutHydrateFallbackand withoutclientLoader.hydrate = truedoes not run on the initial load; it only runs on client navigations.- Server-only modules imported by a route component end up in the client bundle unless
only loaders/actions use them. Name them
*.server.tsor put them under a.server/directory so the build fails if client code imports them. - Resource routes (no default export) return
Responseobjects and are the place for webhooks, file downloads and JSON endpoints. - Using
useEffect+fetchin route components bypasses revalidation and causes waterfalls; move it to a loader.
Verification
-
react-router typegenandtsc --noEmitpass. -
react-router buildsucceeds; start the built server and load each touched URL directly (SSR) and via client navigation. - Submitting forms works with JavaScript disabled for
<Form>routes. - Network tab: after an action, the single-fetch
.datarequest covers only the loaders you expect to revalidate. - Error paths: a missing record shows the route ErrorBoundary with a 404 status.
Output
List each touched route with its URL, loader/action/clientLoader presence, auth rule, and
revalidation customizations. For migrations, list removed @remix-run/* imports and any
remaining TODOs.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "React Router v7"
short_description: "Loaders, actions and fetchers the framework way"
default_prompt: "$react-router-v7 Add a project settings route with a loader, an action and field errors" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.