Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Framework · React Router 8 · React 19 · TypeScript 6 · Vite 8

Remix / React Router

Loaders, actions and web-standard data flow, no client fetch waterfalls.

remixreact-routerssrtypescript

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You 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.x in framework mode — the @react-router/dev Vite plugin. This is the renamed, unified successor to Remix. v8 is ESM-only and the v7 future.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-dom 19.2.x (framework mode requires react@19.2.7+; uses use(), 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 under environments (per-environment), not isSsrBuild. v8's floor is Vite 7; ship 8.
  • TypeScript 6.0.x, strict: true. Route types come from typegen, never hand-written.
  • Node 24 LTS server runtime (fetch/Request/Response globals); v8's baseline is Node 22.22+. Deploy adapter: @react-router/node + @react-router/serve (or @react-router/express); @react-router/cloudflare (with @cloudflare/vite-plugin) / @react-router/architect per host.
  • Zod 4.x for loader/action input validation.
  • Testing: Vitest 4.1.x, @testing-library/react, createRoutesStub (from react-router), Playwright for E2E.
  • Tooling: ESLint 9 flat config (eslint.config.js), Prettier 3, pnpm. Import from react-router (framework mode) — react-router-dom is removed in v8; DOM-only APIs (e.g. RouterProvider) now live at react-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.ts using helpers from @react-router/dev/routes — explicit and typed. Prefer it over the file-name convention; use @react-router/fs-routes flatRoutes() 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, meta reads loaderData (the data arg is removed).
  • Types come from react-router typegen into .react-router/types. Enable in tsconfig.json:
    { "compilerOptions": { "rootDirs": [".", "./.react-router/types"], "types": ["@react-router/node", "vite/client"] } }
    
    Add .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.ts suffixes for environment-locked modules, PascalCase components, camelCase loaders/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 default that 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 with as.

Data loading (loader)

  • All read data comes from a loader (server) or clientLoader (browser-only sources). Never fetch in useEffect, 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() and defer() are removed in framework mode — do not import them.
  • Use data() only to attach a status/headers; throw it for not-found/forbidden so the nearest ErrorBoundary catches 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" };
    }
    
  • clientLoader for data only the browser has (e.g. IndexedDB). If a route has clientLoader but no server loader, export a HydrateFallback for 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().formData before the server responds; reconcile when data revalidates. Do not hand-roll loading state with useState.

Streaming, Suspense & error boundaries

  • Await only critical data; return slow data as an unawaited Promise and render it with <Await> + <Suspense>. No defer() 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 ErrorBoundary for 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 loader and action — 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.ts modules (or app/.server/). Vite hard-errors if a .server module is imported into the client graph, so DB clients, API keys, and process.env secrets never reach the browser. Client-safe env is only import.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 a middleware array (or clientMiddleware for the browser), stash per-request values in a context created with createContext(), and read them downstream via context.get(...) — the request context flows through RouterContextProvider — 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 shouldRevalidate when 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 from fetcher.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 thrown Response.
    const res = await loader({ request: new Request("http://t/products/1"), params: { id: "1" }, context: {} } as Route.LoaderArgs);
    expect(res.product.id).toBe("1");
    
    Test the redirect/403 paths: await expect(loader(argsNoSession)).rejects.toMatchObject({ status: 302 }).
  • Component + routing tests with createRoutesStub (the framework-mode replacement for createRemixStub) + 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> + action must still work).
  • Validate Zod schemas in isolation. Do not mock react-router internals; 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/loader input server-side with Zod. Never trust params, form fields, query, or headers.
  • Prevent open redirects: validate redirect targets against an allowlist / require same-origin relative paths before redirect().
  • Cookies: httpOnly, secure in prod, sameSite: "lax" (or "strict"), rotating secrets array. 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.tsx and pass it to <Scripts nonce={nonce} />; ship a strict Content-Security-Policy, plus X-Content-Type-Options: nosniff and Referrer-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 in action, both on the server; keep components presentational.
  • Run react-router typegen and consume ./+types/*; make typecheck part of the build.
  • Return plain objects; use throw data(msg, { status }) and redirect() 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 only VITE_* env to the client.
  • Trust automatic revalidation; trim it with shouldRevalidate, not manual refetching.

Avoid

  • useEffect + fetch for 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; use data() only for status/headers.
  • Importing from react-router-dom (removed in v8) or @remix-run/* (frozen). Import from react-router; DOM-only APIs live at react-router/dom.
  • remix.config.js — replaced by react-router.config.ts + Vite plugin.
  • Untyped useLoaderData() / useActionData() / useMatches() with as casts — use Route.ComponentProps and typed helpers.
  • Business logic in components, or duplicating server data into useState — mutate on the server and let revalidation update the UI.
  • process.env or DB clients in non-.server modules — 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, and pnpm test; a green typecheck is non-negotiable because route types gate correctness.
  • After changing a loader/action signature, re-run typegen so +types stays accurate.
  • Ask before: adding a client data-fetching library (React Query/SWR — usually redundant here), flipping ssr/prerender mode, changing the runtime adapter, introducing an auth provider, or reshaping the middleware context.

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.

Back to top ↑