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.

  • Frontend
  • $react-router-v7
  • 190 lines
  • 2 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- react-router-v7

Installs to ~/.agents/skills/react-router-v7/ for every project on this machine.

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

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.

~/.agents/skills/react-router-v7/SKILL.md

--- name: react-router-v7 description: Builds React Router v7 framework-mode apps (the Remix successor) — routes.ts config, route modules with loader, action, clientLoader, meta and… license: MIT ---

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

  1. Read AGENTS.md, package.json and react-router.config.ts.
  2. Identify the mode:
    • Framework mode: @react-router/dev Vite plugin, app/routes.ts, route modules. This skill targets it.
    • Data mode: createBrowserRouter with route objects and loaders, no Vite plugin.
    • Declarative mode: <BrowserRouter> and <Routes> only, no loaders.
  3. Check ssr and prerender in react-router.config.ts: ssr: true (server rendering), ssr: false (SPA mode, only clientLoader/clientAction run at runtime), prerender for static paths built at build time.
  4. Check the version for opt-in flags under future (for example v8_middleware for 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 by react-router typegen; add .react-router/ to .gitignore and rootDirs: [".", "./.react-router/types"] in tsconfig.json if the template lacks it.
  • Return plain objects from loaders. json() and defer() from Remix are gone: return promises inside the object to stream them, and wrap with data() only when you need a status or headers.
  • Throw data(...) or redirect(...) 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

NeedUse
Data needed to render a routeloader (server)
Browser-only data or SPA modeclientLoader; 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 popoverfetcher.load("/resource-route?q=...") against a resource route
Optimistic UIread fetcher.formData / navigation.formData while pending
Expensive loader should not rerun after unrelated actionsshouldRevalidate export
Streaming slow datareturn 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

  1. Enable all Remix v2 future flags and fix warnings first; ship that.
  2. Run the community codemod or replace imports by hand: @remix-run/react, @remix-run/node and friends become react-router (runtime adapters move to @react-router/node, @react-router/serve, @react-router/cloudflare).
  3. Swap the Vite plugin to reactRouter() from @react-router/dev/vite, add react-router.config.ts, and create app/routes.ts (use flatRoutes() to keep the existing file naming).
  4. Replace json() and defer() returns with plain objects; replace LoaderFunctionArgs style types with generated Route.* types.
  5. Update scripts: react-router dev, react-router build, and react-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

  • loaderData in meta can be undefined when the loader threw; guard it.
  • clientLoader without HydrateFallback and without clientLoader.hydrate = true does 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.ts or put them under a .server/ directory so the build fails if client code imports them.
  • Resource routes (no default export) return Response objects and are the place for webhooks, file downloads and JSON endpoints.
  • Using useEffect + fetch in route components bypasses revalidation and causes waterfalls; move it to a loader.

Verification

  • react-router typegen and tsc --noEmit pass.
  • react-router build succeeds; 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 .data request 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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem