← All posts
·16 min read

Claude Code with Remix: Loaders, Actions, and Route Conventions

Claude CodeRemixReactWorkflow
Claude Code with Remix: Loaders, Actions, and Route Conventions

Why Remix needs its own CLAUDE.md

Remix has a different mental model from every other React framework, and Claude Code's defaults were shaped by Next.js. That gap produces real problems in practice.

Without a Remix-specific CLAUDE.md, Claude will do all of these things in a Remix project: generate getServerSideProps instead of loader, reach for fetch inside useEffect instead of the loader data already on the route, write useState for form state instead of using Remix's Form component and useActionData, scaffold a custom error UI without wiring it to ErrorBoundary, and use next/navigation imports. None of these fail loudly. They compile, run in development, and break in ways that are tedious to trace.

Remix (now React Router v7 in Framework Mode) is server-first by design. Every route file exports a loader for reads and an action for mutations. The server runs those functions before the page reaches the client. Client-side JavaScript is an enhancement, not the primary data channel. The framework's "use the platform" philosophy means you get Request, Response, FormData, and Headers as first-class primitives, not wrappers around them.

Claude Code does not infer this posture from the presence of a remix.config.js. It needs to be told. This guide covers the CLAUDE.md configuration that anchors Claude to Remix's conventions, with templates for the full loader and action pattern, nested routes, error boundaries, session handling, and the failure modes worth knowing about. If you have not set up Claude Code yet, the Claude Code setup guide covers installation. For the broader principle of why CLAUDE.md matters more than most developers expect, CLAUDE.md explained is the foundation.

The Remix CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every session. For a Remix application it has to declare: the version and variant (Remix v2 or React Router v7 Framework Mode), route file conventions, where loaders and actions live, how forms work, session storage configuration, and the hard rules that stop Claude defaulting to Next.js or client-only patterns.

# Remix (React Router v7) project rules

## Stack
- React Router v7.x Framework Mode (formerly Remix v2)
- Node.js 22.x, TypeScript 5.x strict mode
- Vite 6.x as bundler (vite.config.ts)
- Tailwind CSS 4.x for styling
- Prisma 6.x with PostgreSQL for data

## Project structure
- app/routes/: all route files (file-based routing)
- app/routes/_index.tsx: root index route
- app/routes/dashboard.tsx: layout route for /dashboard/*
- app/routes/dashboard._index.tsx: /dashboard index
- app/routes/dashboard.$projectId.tsx: dynamic route
- app/session.server.ts: createCookieSessionStorage, getSession, commitSession, destroySession
- app/db.server.ts: Prisma client singleton (server-only)
- app/utils/: shared utilities (server files named *.server.ts)

## Route file conventions
- Every route exports: loader (GET data), action (POST/PUT/DELETE), default component
- Loaders run on the server before render, return json() or redirect()
- Actions run on the server for mutations, return json() or redirect()
- Optional exports: meta(), links(), headers(), handle
- Layout routes: file name matches parent segment (e.g. dashboard.tsx wraps dashboard.*.tsx)
- Nested URL segments use dots: dashboard.settings.tsx = /dashboard/settings

## Hard rules
- NEVER use useEffect for data fetching, all data comes through loader
- NEVER use useState for form state, use Remix Form, useActionData, useNavigation
- NEVER import from 'next/*' or use App Router conventions (no 'use client', no 'use server')
- NEVER import Prisma client outside *.server.ts files (breaks Vite client bundle)
- ALWAYS use the json() helper from @remix-run/node (or react-router) to return responses
- ALWAYS return redirect() after successful mutations, never re-render on POST
- ALWAYS name server-only files *.server.ts to prevent client-side import
- ALWAYS type loader with LoaderFunctionArgs, action with ActionFunctionArgs
- ALWAYS export ErrorBoundary from every route that can fail
- Cookies and sessions use createCookieSessionStorage, never localStorage for auth state
- Form submissions use <Form method="post">, not fetch() or axios POST
- File uploads use <Form encType="multipart/form-data"> with unstable_parseMultipartFormData

## Environment
- .env for development, .env.production for prod (Vite-read)
- Server-only secrets accessible via process.env (not import.meta.env)
- PUBLIC_ prefix convention: PUBLIC_APP_URL is safe for client; DATABASE_URL is server-only
- Never access process.env in client components, use loader to pass public config

Five rules in this template prevent the failures Claude produces most often in Remix projects.

The no useEffect for data fetching rule is the most impactful single line. Claude's React training is enormous and the majority of it shows useEffect(() => { fetch('/api/data').then(...) }, []). Remix makes this pattern unnecessary and actively harmful: the data has already been fetched by the loader before the component renders. Every useEffect fetch in a Remix route bypasses the framework's caching, streaming, and error boundary integration. Once the rule is in CLAUDE.md, Claude uses useLoaderData() every time.

The no useState for form state rule has the same shape. Claude defaults to controlled inputs with useState because that is the dominant React pattern. In Remix, <Form method="post"> submits to the route's action, and useActionData() returns the result. useNavigation() gives you pending state. There is nothing to control. The pattern composes with Remix's optimistic UI, pending UI, and revalidation system; useState does not.

The server file naming rule prevents silent bugs. Vite will attempt to bundle any import into the client bundle unless the file is explicitly excluded. A Prisma client import in a route file that does not have .server.ts somewhere in the import chain will cause Vite to try to bundle @prisma/client for the browser. It fails at build time with a confusing error. Naming server files *.server.ts and importing only them from route files keeps the boundary clean.

The Post/Redirect/Get rule (return redirect() after mutations) prevents the double-submit problem where refreshing the browser replays the last POST. Remix enforces this through convention, but Claude will sometimes return a JSON response from an action when a redirect is appropriate. The rule makes the correct pattern explicit.

Loaders and actions: the full pattern

Loaders handle reads. Actions handle writes. This is the complete mental model for server-side data in Remix, and it replaces API routes, getServerSideProps, and client-side fetch hooks.

Add a loaders-and-actions section to CLAUDE.md:

## Loader and action patterns

### Canonical loader
import type { LoaderFunctionArgs } from "react-router";
import { json } from "@remix-run/node";
import { useLoaderData } from "react-router";
import { requireUser } from "~/session.server";
import { db } from "~/db.server";

export async function loader({ request, params }: LoaderFunctionArgs) {
  const user = await requireUser(request);
  const project = await db.project.findUniqueOrThrow({
    where: { id: params.projectId, userId: user.id },
    select: { id: true, name: true, status: true, createdAt: true },
  });
  return json({ project });
}

export default function ProjectPage() {
  const { project } = useLoaderData<typeof loader>();
  return <h1>{project.name}</h1>;
}

### Canonical action
import type { ActionFunctionArgs } from "react-router";
import { json, redirect } from "@remix-run/node";
import { z } from "zod";
import { useActionData, Form, useNavigation } from "react-router";

const UpdateProjectSchema = z.object({
  name: z.string().min(1).max(100),
  status: z.enum(["active", "archived"]),
});

export async function action({ request, params }: ActionFunctionArgs) {
  const user = await requireUser(request);
  const formData = await request.formData();
  const result = UpdateProjectSchema.safeParse({
    name: formData.get("name"),
    status: formData.get("status"),
  });

  if (!result.success) {
    return json({ errors: result.error.flatten() }, { status: 400 });
  }

  await db.project.update({
    where: { id: params.projectId, userId: user.id },
    data: result.data,
  });

  return redirect(`/dashboard/${params.projectId}`);
}

export default function EditProjectPage() {
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";

  return (
    <Form method="post">
      <input name="name" defaultValue="" />
      {actionData?.errors?.fieldErrors.name && (
        <p>{actionData.errors.fieldErrors.name[0]}</p>
      )}
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Saving..." : "Save"}
      </button>
    </Form>
  );
}

The typeof loader type parameter on useLoaderData<typeof loader>() is what makes the data type flow from server to client without a separate type declaration. Claude generates this correctly once the pattern is in CLAUDE.md, and omits the type parameter (returning unknown) when it is not.

The action's formData.get() calls return FormDataEntryValue | null, which is a string or a File. Parsing through Zod is the right approach because it coerces to the right type and provides structured error output. The result.error.flatten() shape maps directly to field-level errors in the UI. Claude generates raw formData.get("name") as string without Zod when the pattern is not in CLAUDE.md, which passes type checks but fails at runtime when the field is absent.

The Post/Redirect/Get flow in the action (redirect() on success, json({ errors }) on failure) means the component handles two states: no actionData (first load or after successful redirect) and actionData with errors (failed submission). This is simpler than maintaining a local submission state machine with useState.

The useNavigation hook replaces the need to track loading state manually. navigation.state is "idle", "loading", or "submitting". Disabling the submit button during "submitting" is one line. Claude misses this and scaffolds a const [isLoading, setIsLoading] = useState(false) pattern when the action pattern is not in CLAUDE.md.

Nested routes and layout boundaries

Nested routes are where Remix diverges most sharply from every other framework, and where Claude generates the most wrong code without guidance. Understanding the file naming convention is prerequisite knowledge for getting Claude to generate correct scaffolding.

Add a nested routes section to CLAUDE.md:

## Nested routes and layout routes

### File naming convention
- app/routes/dashboard.tsx          → layout route, renders <Outlet /> for all /dashboard/* routes
- app/routes/dashboard._index.tsx   → renders at /dashboard (no sub-path)
- app/routes/dashboard.$projectId.tsx → renders at /dashboard/abc123
- app/routes/dashboard.settings.tsx → renders at /dashboard/settings
- app/routes/_auth.tsx              → pathless layout (prefixed with _), no URL segment
- app/routes/_auth.login.tsx        → renders at /login, wrapped by _auth layout

### Layout routes MUST export Outlet
import { Outlet } from "react-router";

export default function DashboardLayout() {
  return (
    <div className="dashboard">
      <Sidebar />
      <main>
        <Outlet />
      </main>
    </div>
  );
}

### Each nested route gets its own loader
// dashboard.tsx (layout)
export async function loader({ request }: LoaderFunctionArgs) {
  const user = await requireUser(request);
  return json({ user });
}

// dashboard.$projectId.tsx (child)
export async function loader({ request, params }: LoaderFunctionArgs) {
  const user = await requireUser(request);
  const project = await db.project.findUniqueOrThrow({
    where: { id: params.projectId, userId: user.id },
  });
  return json({ project });
}

### useRouteLoaderData for accessing ancestor loader data
import { useRouteLoaderData } from "react-router";
import type { loader as dashboardLoader } from "~/routes/dashboard";

const parentData = useRouteLoaderData<typeof dashboardLoader>("routes/dashboard");

### Rules
- Layout route Outlet is required, without it child routes render nowhere
- Each route has its own loader, do not pass data down through props across route boundaries
- Pathless layouts (_prefix) share UI without adding a URL segment
- useOutletContext() for passing data from parent Outlet to children (use sparingly)

The layout route and <Outlet /> relationship is the single biggest source of confusion when Claude scaffolds Remix routes without context. Claude will sometimes generate a layout route file without an <Outlet /> export, which means every child route silently renders into a void. The rule is explicit: if the file is a layout route, it must export <Outlet />.

The useRouteLoaderData pattern matters for accessing data from ancestor loaders without prop drilling. Claude will sometimes generate a second DB query in the child loader to refetch data the parent already loaded. With the pattern in CLAUDE.md, Claude uses useRouteLoaderData with the parent route's loader type instead.

Pathless layouts (the _ prefix convention) are worth declaring explicitly because Claude tends to create a URL segment when none is needed. A _auth.tsx layout wraps all auth routes under a shared header and background without adding /auth to the URL. The underscore prefix is a convention Claude respects when it is documented, and ignores when it is not.

Session handling and authentication

Remix's session model uses createCookieSessionStorage, getSession, commitSession, and destroySession from the framework itself. There is no third-party session library to configure. The session is a signed cookie; the signature key is your session secret.

Add a sessions section to CLAUDE.md:

## Sessions and authentication (app/session.server.ts)

### Canonical session setup
import { createCookieSessionStorage, redirect } from "@remix-run/node";

const sessionStorage = createCookieSessionStorage({
  cookie: {
    name: "__session",
    httpOnly: true,
    path: "/",
    sameSite: "lax",
    secrets: [process.env.SESSION_SECRET!],
    secure: process.env.NODE_ENV === "production",
  },
});

export async function getSession(request: Request) {
  return sessionStorage.getSession(request.headers.get("Cookie"));
}

export async function commitSession(session: Awaited<ReturnType<typeof getSession>>) {
  return sessionStorage.commitSession(session);
}

export async function destroySession(session: Awaited<ReturnType<typeof getSession>>) {
  return sessionStorage.destroySession(session);
}

### requireUser helper
export async function requireUser(request: Request) {
  const session = await getSession(request);
  const userId = session.get("userId");
  if (!userId) throw redirect("/login");
  const user = await db.user.findUnique({ where: { id: userId } });
  if (!user) throw redirect("/login");
  return user;
}

### Login action pattern
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const email = String(formData.get("email"));
  const password = String(formData.get("password"));

  const user = await verifyLogin(email, password);
  if (!user) {
    return json({ error: "Invalid credentials" }, { status: 401 });
  }

  const session = await getSession(request);
  session.set("userId", user.id);

  return redirect("/dashboard", {
    headers: { "Set-Cookie": await commitSession(session) },
  });
}

### Logout action pattern
export async function action({ request }: ActionFunctionArgs) {
  const session = await getSession(request);
  return redirect("/login", {
    headers: { "Set-Cookie": await destroySession(session) },
  });
}

### Rules
- SESSION_SECRET must be a long random string (32+ chars), never hardcoded
- Always use httpOnly: true (prevents XSS access to the session cookie)
- Always use sameSite: "lax" (prevents CSRF on most mutation flows)
- Commit the session in every response that mutates it (Set-Cookie header required)
- Use throw redirect() inside requireUser to short-circuit route rendering

The session pattern here is pure Remix with no library dependencies beyond the framework. Claude will sometimes reach for express-session, iron-session, or NextAuth when working in Remix without guidance, because those are the patterns it has seen most. With the canonical createCookieSessionStorage pattern in CLAUDE.md, Claude stays within the framework.

The throw redirect() inside requireUser is a Remix-specific idiom worth documenting explicitly. In Remix, you can throw a Response or a redirect from a loader or action and the framework catches it, ending the render cycle immediately. This means requireUser does not return null on failure; it interrupts execution. Claude sometimes generates the check as if (!user) return redirect("/login") at the call site, which requires the caller to handle the null case. throw redirect() inside the helper is simpler and more composable.

The Set-Cookie header handling is what most developers miss first. In Remix, mutating the session and then redirecting requires passing the Set-Cookie header explicitly in the redirect response. Without it, the session mutation is lost. Claude will sometimes omit the headers object on the redirect when this is not documented. The pattern in CLAUDE.md shows both the commit and the header together, so Claude generates both.

Error boundaries and failure handling

Every Remix route can export an ErrorBoundary that catches errors thrown from its loader, action, or component. This is the replacement for top-level error pages and the equivalent of React's componentDidCatch, but integrated with routing. Without it in CLAUDE.md, Claude either skips error boundaries entirely or generates a generic React error boundary that does not receive Remix error context.

Add an error boundary section to CLAUDE.md:

## Error boundaries (every route that can fail)

### Route-level ErrorBoundary
import { useRouteError, isRouteErrorResponse } from "react-router";

export function ErrorBoundary() {
  const error = useRouteError();

  if (isRouteErrorResponse(error)) {
    // Thrown Response objects (e.g. throw new Response("Not Found", { status: 404 }))
    return (
      <div>
        <h1>{error.status} {error.statusText}</h1>
        <p>{error.data}</p>
      </div>
    );
  }

  // Unexpected thrown errors (runtime exceptions)
  const message = error instanceof Error ? error.message : "Unknown error";
  return (
    <div>
      <h1>Something went wrong</h1>
      <p>{message}</p>
    </div>
  );
}

### Throwing meaningful errors from loaders
export async function loader({ params }: LoaderFunctionArgs) {
  const project = await db.project.findUnique({ where: { id: params.projectId } });
  if (!project) {
    throw new Response("Project not found", { status: 404 });
  }
  return json({ project });
}

### Rules
- Export ErrorBoundary from every route that has a loader or action
- Use isRouteErrorResponse() to differentiate thrown Responses from runtime errors
- Throw new Response("message", { status: N }) for expected errors (not found, forbidden)
- Let unexpected errors (db connection failure, etc.) bubble as plain thrown errors
- The root route's ErrorBoundary is the last resort, keep it generic but always present
- Never swallow errors silently in loaders, throw or return, never return undefined

The isRouteErrorResponse check is the key distinction. A 404 you throw from a loader is a RouteErrorResponse with status: 404 and a data string. A TypeError from a broken DB query is a plain Error. The ErrorBoundary needs to handle both shapes, and Claude will generate only one branch when the pattern is not explicit.

The rule "never return undefined from a loader" prevents a class of subtle bugs. A loader that returns undefined (forgot the return json(...)) causes useLoaderData() to return undefined in the component, which typically crashes with a cryptic type error rather than a helpful error boundary. Returning json({}) or throwing is always correct; returning nothing is never correct.

The root route's ErrorBoundary is the safety net for any error that escapes a route-level boundary. Keeping it simple and always present means unhandled errors degrade gracefully rather than crashing the entire application.

Common failure modes Claude generates without context

With no Remix-specific CLAUDE.md, Claude generates predictable categories of wrong code. Documenting these in CLAUDE.md as explicit prohibitions prevents each one.

## Anti-patterns, never generate these in this project

### Client-side fetch instead of loader
// WRONG
export default function Page() {
  const [data, setData] = useState(null);
  useEffect(() => {
    fetch("/api/data").then(r => r.json()).then(setData);
  }, []);
}

// RIGHT: use loader + useLoaderData

### API route for internal data
// WRONG: creating app/routes/api.projects.ts as a JSON API for the frontend
// RIGHT: put the logic in the route's loader directly

### Next.js imports in a Remix file
// WRONG
import { useRouter } from "next/navigation";
import { redirect } from "next/navigation";
// RIGHT
import { useNavigate, redirect } from "react-router";

### useState for form submission state
// WRONG
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e) => {
  e.preventDefault();
  setIsLoading(true);
  await fetch("/api/update", { method: "POST", body: formData });
  setIsLoading(false);
};
// RIGHT: use <Form method="post"> and useNavigation().state

### Prisma import in a non-server file
// WRONG: import { db } from "~/db" in a route component
// RIGHT: import { db } from "~/db.server", only in loader/action, never in component scope

### Missing redirect after action success
// WRONG
export async function action({ request }: ActionFunctionArgs) {
  await db.project.create({ data: formData });
  return json({ success: true }); // stays on the POST URL
}
// RIGHT: return redirect("/dashboard") after successful mutation

Each of these anti-patterns appears in Claude-generated Remix code within three or four prompts when there is no CLAUDE.md. Each one compiles, most run in development, and each breaks in a different way in production or under a page refresh.

The API route anti-pattern is worth calling out separately. In Next.js App Router, it is idiomatic to create route handlers at app/api/projects/route.ts and fetch them from client components. In Remix, this is unnecessary for internal data. The loader is the API. Creating a separate API route that the frontend then fetches adds a network round trip, bypasses Remix's data loading primitives, and breaks streaming. Claude generates this pattern in Remix projects when it has App Router experience and no Remix-specific guidance.

Putting it together: Claude Code as a Remix pair programmer

A Remix project with the CLAUDE.md template above gives Claude enough context to generate correct code across the full range of Remix patterns. Loaders return typed data that flows directly into components via useLoaderData<typeof loader>(). Actions parse FormData through Zod, return typed errors via useActionData<typeof action>(), and redirect on success. Nested routes export <Outlet /> and load their own data independently. Sessions use createCookieSessionStorage with requireUser throwing a redirect on failure. Error boundaries handle both RouteErrorResponse and plain errors.

The underlying reason this works is the same as for any framework: Claude performs at the level of context you give it. A Remix project with no CLAUDE.md is indistinguishable to Claude from a Next.js or generic React project. It will apply whatever patterns were most common in its training data. A project with the configuration above gives Claude a clear Remix-specific mental model to work from, and the hard rules make the boundaries explicit.

For the permissions configuration that gates destructive scripts in a Remix project, Claude Code permissions covers the .claude/settings.local.json pattern. For TypeScript configuration that pairs with the strict mode declared in the template above, Claude Code with TypeScript covers the tsconfig.json and type-safety patterns. For the broader workflow of combining Claude Code with React components and hooks, Claude Code with React and Claude Code with Next.js show the same CLAUDE.md approach applied to adjacent frameworks. The environment variable handling in the session template connects directly to the patterns in Claude Code with environment variables.

Claudify includes a Remix-specific CLAUDE.md template, pre-configured for the loader and action patterns, nested route conventions, session handling, and the anti-pattern prohibitions that stop Claude defaulting to Next.js in a Remix codebase.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir