Remix bets on the web platform: forms submit to server actions, data loads in parallel through route loaders, and mutations work even without JavaScript. Claude Code understands Remix’s file-based routing, nested routes, and the delineation between loaders (data) and actions (mutations) — the patterns that eliminate most need for global state management.
This guide covers Remix with Claude Code: loaders, actions, nested routing, error handling, and optimistic UI.
Remix Project Setup
CLAUDE.md for Remix Projects
## Remix Project
- Framework: Remix 2.x with Vite
- Runtime: Cloudflare Workers (via @remix-run/cloudflare)
- Database: Cloudflare D1 (SQLite) via Drizzle ORM
- Auth: remix-auth (server-side sessions, Cloudflare KV)
- Styling: Tailwind CSS
## Key conventions
- Routes: app/routes/ (file-based, . = nested path, _ = layout without URL segment)
- Loaders: export async function loader({ request, params, context }) — GET data
- Actions: export async function action({ request, params, context }) — mutations
- Server-only: .server.ts files (never bundled to client)
- No useState for server data — use loader + useLoaderData
- Errors: throw Response objects or redirect() in loaders/actions (not try/catch at component level)
## Rendering model
- All routes render on server by default
- Don't use fetch() in components — use loader + useFetcher
- Form submissions go to action (same route by default, or action="/other-route")
Route File Layout
app/
routes/
_index.tsx # / (homepage)
blog._index.tsx # /blog (blog listing)
blog.$slug.tsx # /blog/:slug (blog post)
app.tsx # Layout for /app/* (sidebar, auth check)
app._index.tsx # /app (dashboard)
app.settings.tsx # /app/settings
app.settings.profile.tsx # /app/settings/profile
api.posts.tsx # /api/posts (API endpoint)
Loaders
The blog listing page needs posts from the database with pagination.
Load it server-side and make it fast.
// app/routes/blog._index.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/cloudflare';
import { useLoaderData, Link } from '@remix-run/react';
import { getPosts } from '~/lib/posts.server';
export async function loader({ request, context }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') ?? '1');
const { posts, total } = await getPosts(context.env.DB, { page, limit: 20 });
return json(
{ posts, total, page },
{
headers: {
'Cache-Control': 'public, max-age=300, stale-while-revalidate=3600',
},
}
);
}
export default function BlogIndex() {
const { posts, total, page } = useLoaderData<typeof loader>();
const totalPages = Math.ceil(total / 20);
return (
<main>
<h1>Blog</h1>
<ul>
{posts.map(post => (
<li key={post.id}>
<Link to={`/blog/${post.slug}`}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<time dateTime={post.createdAt}>{formatDate(post.createdAt)}</time>
</Link>
</li>
))}
</ul>
<nav aria-label="Pagination">
{page > 1 && <Link to={`?page=${page - 1}`}>Previous</Link>}
<span>Page {page} of {totalPages}</span>
{page < totalPages && <Link to={`?page=${page + 1}`}>Next</Link>}
</nav>
</main>
);
}
Parallel Loaders in Nested Routes
The app layout needs the current user,
and each subroute needs its own data.
Load them in parallel.
// app/routes/app.tsx — layout route
export async function loader({ request, context }: LoaderFunctionArgs) {
const session = await getSession(context, request.headers.get('Cookie'));
if (!session.userId) {
throw redirect('/login');
}
const user = await getUser(context.env.DB, session.userId);
if (!user) throw redirect('/login');
return json({ user });
}
export default function AppLayout() {
const { user } = useLoaderData<typeof loader>();
return (
<div className="app-shell">
<nav>
<Link to="/app">Dashboard</Link>
<Link to="/app/settings">Settings</Link>
<span>{user.name}</span>
</nav>
{/* Nested route renders here */}
<main><Outlet /></main>
</div>
);
}
// app/routes/app._index.tsx — /app route
// Runs SIMULTANEOUSLY with app.tsx loader — Remix parallelizes them
export async function loader({ request, context }: LoaderFunctionArgs) {
const session = await getSession(context, request.headers.get('Cookie'));
const [stats, recentActivity] = await Promise.all([
getDashboardStats(context.env.DB, session.userId),
getRecentActivity(context.env.DB, session.userId),
]);
return json({ stats, recentActivity });
}
Actions
Create a contact form with server-side validation.
Show inline field errors. Redirect to success page on submit.
// app/routes/contact.tsx
import {
json,
redirect,
type ActionFunctionArgs,
} from '@remix-run/cloudflare';
import { useActionData, Form } from '@remix-run/react';
import { z } from 'zod';
const contactSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
subject: z.string().min(1, 'Subject is required'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
type ActionData = {
errors?: Partial<Record<keyof z.infer<typeof contactSchema>, string>>;
values?: Record<string, string>;
};
export async function action({ request, context }: ActionFunctionArgs) {
const formData = await request.formData();
const rawData = Object.fromEntries(formData);
const result = contactSchema.safeParse(rawData);
if (!result.success) {
return json<ActionData>({
errors: Object.fromEntries(
Object.entries(result.error.flatten().fieldErrors).map(
([key, msgs]) => [key, msgs?.[0] ?? 'Invalid input']
)
),
values: rawData as Record<string, string>,
}, { status: 422 });
}
// Send the email
await sendEmail(context.env, result.data);
// Redirect — prevents duplicate submissions on refresh
throw redirect('/contact/success');
}
export default function Contact() {
const actionData = useActionData<typeof action>();
return (
<Form method="POST">
<div>
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
type="text"
defaultValue={actionData?.values?.name}
aria-describedby={actionData?.errors?.name ? 'name-error' : undefined}
aria-invalid={!!actionData?.errors?.name}
/>
{actionData?.errors?.name && (
<span id="name-error" role="alert">{actionData.errors.name}</span>
)}
</div>
{/* Similar fields for email, subject, message */}
<button type="submit">Send message</button>
</Form>
);
}
The form works with or without JavaScript — the browser POSTs the form data, the action runs on the server, the redirect happens. React’s enhancement layer adds instant validation and loading states on top.
Optimistic UI with useFetcher
We have a like button. The network round trip makes it feel sluggish.
Add optimistic updating — show the new state immediately.
// app/components/LikeButton.tsx
import { useFetcher } from '@remix-run/react';
export function LikeButton({ postId, initialLiked, initialCount }: {
postId: string;
initialLiked: boolean;
initialCount: number;
}) {
const fetcher = useFetcher<{ liked: boolean; count: number }>();
// Optimistic values — use fetcher's submitted state if available
const optimisticLiked = fetcher.formData
? fetcher.formData.get('action') === 'like'
: initialLiked;
const optimisticCount = fetcher.formData
? initialCount + (fetcher.formData.get('action') === 'like' ? 1 : -1)
: fetcher.data?.count ?? initialCount;
return (
<fetcher.Form method="POST" action={`/api/posts/${postId}/like`}>
<input
type="hidden"
name="action"
value={optimisticLiked ? 'unlike' : 'like'}
/>
<button
type="submit"
aria-label={optimisticLiked ? 'Unlike post' : 'Like post'}
aria-pressed={optimisticLiked}
>
{optimisticLiked ? '♥' : '♡'} {optimisticCount}
</button>
</fetcher.Form>
);
}
// app/routes/api.posts.$id.like.tsx
export async function action({ params, request, context }: ActionFunctionArgs) {
const session = await getSession(context, request.headers.get('Cookie'));
if (!session.userId) throw json({ error: 'Unauthorized' }, 401);
const formData = await request.formData();
const action = formData.get('action');
const result = action === 'like'
? await likePost(context.env.DB, params.id!, session.userId)
: await unlikePost(context.env.DB, params.id!, session.userId);
return json(result);
}
Error Boundaries
If a route throws during loading, show a friendly error
instead of crashing the whole app.
// app/routes/blog.$slug.tsx
export function ErrorBoundary() {
const error = useRouteError();
// Handle Response errors (like throw redirect or throw json)
if (isRouteErrorResponse(error)) {
if (error.status === 404) {
return (
<div>
<h1>Post not found</h1>
<p>This post may have been moved or deleted.</p>
<Link to="/blog">Back to blog</Link>
</div>
);
}
return (
<div>
<h1>Error {error.status}</h1>
<p>{error.data}</p>
</div>
);
}
// Unhandled errors (bugs)
console.error(error);
return (
<div>
<h1>Something went wrong</h1>
<p>We've been notified. Try refreshing the page.</p>
</div>
);
}
export async function loader({ params, context }: LoaderFunctionArgs) {
const post = await getPostBySlug(context.env.DB, params.slug!);
// This throws a 404 Response — caught by ErrorBoundary above
if (!post) throw json('Post not found', { status: 404 });
return json({ post });
}
For Next.js App Router — the other major React full-stack framework — see the Next.js App Router guide for a comparison of the approaches. For deploying Remix to Cloudflare Workers, the serverless guide covers the deployment process. The Claude Skills 360 bundle includes Remix skill sets for full-stack React patterns. Start with the free tier to generate loader and action patterns for your routes.