Remix is a full-stack web framework built on web standards — Request, Response, URL, FormData. Nested routes mean layouts and data loading are colocated with routes. Loaders fetch data on the server before rendering. Actions handle mutations and return to the same route with fresh data. useFetcher enables optimistic UI without page navigation. Error boundaries isolate failures to the affected route subtree. Claude Code generates Remix route modules, loader/action pairs, nested layout hierarchies, and the resource routes that serve APIs and file downloads.
CLAUDE.md for Remix Projects
## Remix Stack
- Version: Remix 2.x with Vite
- Runtime: Cloudflare Workers (preferred) or Node.js
- TypeScript: strict mode, typed loader/action returns
- Database: Drizzle ORM with D1 (Cloudflare) or PostgreSQL
- Auth: remix-auth with cookie sessions
- Forms: native HTML forms + Remix actions (no extra library)
- Validation: Zod on both client (schema) and server (action)
- Error: ErrorBoundary on every route, CatchBoundary for 404
- Styling: Tailwind CSS
Nested Route Architecture
app/
├── root.tsx # Root layout
├── routes/
│ ├── _index.tsx # / (home)
│ ├── _auth.tsx # Auth layout (no URL segment)
│ ├── _auth.login.tsx # /login
│ ├── _auth.register.tsx # /register
│ ├── dashboard.tsx # /dashboard (layout)
│ ├── dashboard._index.tsx # /dashboard (index)
│ ├── dashboard.orders.tsx # /dashboard/orders
│ ├── dashboard.orders.$id.tsx # /dashboard/orders/:id
│ └── api.orders.ts # /api/orders (resource route)
Root Route and Error Boundary
// app/root.tsx
import {
Links, Meta, Outlet, Scripts, ScrollRestoration,
isRouteErrorResponse, useRouteError,
} from '@remix-run/react'
import type { LinksFunction, LoaderFunctionArgs } from '@remix-run/cloudflare'
import { json } from '@remix-run/cloudflare'
import { getSession } from '@/lib/session.server'
import stylesheet from '@/styles/tailwind.css?url'
export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: stylesheet },
]
export async function loader({ request, context }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
return json({ user: session.get('user') ?? null })
}
export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}
export function ErrorBoundary() {
const error = useRouteError()
if (isRouteErrorResponse(error)) {
return (
<html>
<body className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-4xl font-bold">{error.status}</h1>
<p>{error.statusText}</p>
</div>
</body>
</html>
)
}
return (
<html>
<body>
<h1>Something went wrong</h1>
<p>{error instanceof Error ? error.message : 'Unknown error'}</p>
</body>
</html>
)
}
Nested Dashboard Layout
// app/routes/dashboard.tsx — shared layout for all /dashboard/* routes
import { Outlet, NavLink, useLoaderData } from '@remix-run/react'
import type { LoaderFunctionArgs } from '@remix-run/cloudflare'
import { json, redirect } from '@remix-run/cloudflare'
import { requireUser } from '@/lib/auth.server'
export async function loader({ request, context }: LoaderFunctionArgs) {
const user = await requireUser(request) // Redirects to /login if not authenticated
const { env } = context.cloudflare
const stats = await env.DB.prepare(
'SELECT COUNT(*) as orders, SUM(total_cents) as revenue FROM orders WHERE user_id = ?'
).bind(user.id).first()
return json({ user, stats })
}
export default function DashboardLayout() {
const { user, stats } = useLoaderData<typeof loader>()
return (
<div className="flex min-h-screen">
<nav className="w-64 bg-gray-900 text-white p-6">
<h2 className="font-bold text-xl mb-6">Dashboard</h2>
<ul className="space-y-2">
<li>
<NavLink
to="/dashboard"
end
className={({ isActive }) =>
`block px-3 py-2 rounded ${isActive ? 'bg-blue-600' : 'hover:bg-gray-700'}`
}
>
Overview
</NavLink>
</li>
<li>
<NavLink
to="/dashboard/orders"
className={({ isActive }) =>
`block px-3 py-2 rounded ${isActive ? 'bg-blue-600' : 'hover:bg-gray-700'}`
}
>
Orders
</NavLink>
</li>
</ul>
</nav>
<main className="flex-1 p-8">
<Outlet /> {/* Child routes render here */}
</main>
</div>
)
}
Orders Route with Loader and Action
// app/routes/dashboard.orders.tsx
import {
useLoaderData, useActionData, Form, useFetcher, useNavigation,
} from '@remix-run/react'
import type { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/cloudflare'
import { json, redirect } from '@remix-run/cloudflare'
import { z } from 'zod'
import { requireUser } from '@/lib/auth.server'
// Zod schema for action validation
const UpdateStatusSchema = z.object({
orderId: z.string().min(1),
status: z.enum(['PENDING', 'PROCESSING', 'SHIPPED', 'DELIVERED', 'CANCELLED']),
})
export async function loader({ request, context }: LoaderFunctionArgs) {
const user = await requireUser(request)
const { env } = context.cloudflare
const url = new URL(request.url)
const status = url.searchParams.get('status')
const page = parseInt(url.searchParams.get('page') ?? '1')
const pageSize = 20
let query = 'SELECT * FROM orders WHERE user_id = ?'
const params: (string | number)[] = [user.id]
if (status) {
query += ' AND status = ?'
params.push(status)
}
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'
params.push(pageSize, (page - 1) * pageSize)
const orders = await env.DB.prepare(query).bind(...params).all()
return json({ orders: orders.results, page, user })
}
export async function action({ request, context }: ActionFunctionArgs) {
const user = await requireUser(request)
const { env } = context.cloudflare
const formData = await request.formData()
const parsed = UpdateStatusSchema.safeParse({
orderId: formData.get('orderId'),
status: formData.get('status'),
})
if (!parsed.success) {
return json({ errors: parsed.error.flatten() }, { status: 400 })
}
const { orderId, status } = parsed.data
// Verify ownership before updating
const order = await env.DB.prepare(
'SELECT id FROM orders WHERE id = ? AND user_id = ?'
).bind(orderId, user.id).first()
if (!order) {
return json({ errors: { general: 'Order not found' } }, { status: 404 })
}
await env.DB.prepare(
'UPDATE orders SET status = ?, updated_at = ? WHERE id = ?'
).bind(status, new Date().toISOString(), orderId).run()
return json({ success: true })
}
export default function OrdersPage() {
const { orders, page } = useLoaderData<typeof loader>()
const actionData = useActionData<typeof action>()
const navigation = useNavigation()
// useFetcher for non-navigation mutations (update without redirect)
const fetcher = useFetcher<typeof action>()
const isUpdating = (orderId: string) =>
fetcher.state !== 'idle' &&
fetcher.formData?.get('orderId') === orderId
return (
<div>
<h1 className="text-2xl font-bold mb-6">Orders</h1>
{/* Filter form — navigates with URL params */}
<Form method="get" className="mb-6 flex gap-2">
<select name="status" className="border rounded px-3 py-2">
<option value="">All statuses</option>
{['PENDING', 'PROCESSING', 'SHIPPED', 'DELIVERED'].map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
<button type="submit" className="btn btn-secondary">Filter</button>
</Form>
<div className="space-y-4">
{orders.map((order: any) => (
<div key={order.id} className="border rounded-lg p-4">
<div className="flex justify-between items-center">
<div>
<span className="font-mono">{order.id}</span>
<span className="ml-3 text-gray-600">${(order.total_cents / 100).toFixed(2)}</span>
</div>
{/* Inline status update without page reload */}
<fetcher.Form method="post">
<input type="hidden" name="orderId" value={order.id} />
<select
name="status"
defaultValue={order.status}
onChange={e => {
const form = e.currentTarget.closest('form')!
fetcher.submit(form)
}}
className="border rounded px-2 py-1 text-sm"
disabled={isUpdating(order.id)}
>
{['PENDING', 'PROCESSING', 'SHIPPED', 'DELIVERED', 'CANCELLED'].map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
{isUpdating(order.id) && <span className="ml-2 text-sm text-gray-400">Saving...</span>}
</fetcher.Form>
</div>
</div>
))}
</div>
{/* Pagination */}
<div className="flex gap-2 mt-6">
{page > 1 && (
<Form method="get">
<input type="hidden" name="page" value={page - 1} />
<button type="submit" className="btn">Previous</button>
</Form>
)}
<Form method="get">
<input type="hidden" name="page" value={page + 1} />
<button type="submit" className="btn">Next</button>
</Form>
</div>
</div>
)
}
Resource Route (API Endpoint)
// app/routes/api.orders.ts — JSON API without UI
import type { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/cloudflare'
import { json } from '@remix-run/cloudflare'
import { requireUser } from '@/lib/auth.server'
export async function loader({ request, context }: LoaderFunctionArgs) {
const user = await requireUser(request)
const { env } = context.cloudflare
const orders = await env.DB.prepare(
'SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 50'
).bind(user.id).all()
return json(
{ orders: orders.results },
{ headers: { 'Cache-Control': 'private, max-age=60' } }
)
}
export async function action({ request, context }: ActionFunctionArgs) {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 })
}
const user = await requireUser(request)
const body = await request.json() as any
// Create order
const id = crypto.randomUUID()
await context.cloudflare.env.DB.prepare(
'INSERT INTO orders (id, user_id, status, total_cents) VALUES (?, ?, ?, ?)'
).bind(id, user.id, 'PENDING', body.totalCents).run()
return json({ id }, { status: 201 })
}
Optimistic UI Pattern
// Optimistic like button — responds instantly
function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const fetcher = useFetcher()
const liked = fetcher.formData?.get('liked') === 'true'
const optimisticLikes = fetcher.formData
? initialLikes + (liked ? 1 : -1)
: initialLikes
return (
<fetcher.Form method="post" action={`/api/posts/${postId}/like`}>
<input type="hidden" name="liked" value={String(!liked)} />
<button type="submit" className="flex items-center gap-1">
<span>{liked ? '❤️' : '🤍'}</span>
<span>{optimisticLikes}</span>
</button>
</fetcher.Form>
)
}
For the Next.js App Router alternative with React Server Components, see the Next.js App Router guide for server/client component patterns. For the Cloudflare D1 database and R2 storage that power Remix on Cloudflare Workers, the Cloudflare Workers guide covers the edge runtime environment. The Claude Skills 360 bundle includes Remix skill sets covering nested routes, loaders/actions, and Cloudflare deployment. Start with the free tier to try Remix route generation.