Claude Code for Remix Advanced: Loaders, Actions, and Nested Routing — Claude Skills 360 Blog
Blog / Frontend / Claude Code for Remix Advanced: Loaders, Actions, and Nested Routing
Frontend

Claude Code for Remix Advanced: Loaders, Actions, and Nested Routing

Published: December 25, 2026
Read time: 9 min read
By: Claude Skills 360

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.

Keep Reading

Frontend

Claude Code for Chart.js Advanced: Custom Plugins and Mixed Charts

Advanced Chart.js patterns with Claude Code — chart.register() for tree-shaking, mixed chart types combining bar and line, custom plugin API with beforeDraw and afterDatasetsDraw hooks, ScriptableContext for computed colors, ChartDataLabels plugin for value labels, chartjs-plugin-zoom for pan and zoom, custom gradient fills via ctx.createLinearGradient, ChartJS annotation plugin for threshold lines, streaming data with chartjs-plugin-streaming, and react-chartjs-2 with useRef and chart instance.

6 min read Jun 27, 2027
Frontend

Claude Code for Nivo: Rich SVG and Canvas Charts

Build rich data visualizations with Nivo and Claude Code — ResponsiveLine and ResponsiveBar for adaptive charts, ResponsiveHeatMap for matrix data, ResponsiveTreeMap for hierarchal data, ResponsiveSunburst for nested proportions, ResponsiveChord for relationship diagrams, ResponsiveCalendar for activity heat maps, ResponsiveNetwork for force graphs, NivoTheme for consistent styling, tooltip customization with sliceTooltip, and motion config for spring animations.

6 min read Jun 26, 2027
Frontend

Claude Code for Victory Charts: React Native and Web Charts

Build cross-platform charts with Victory and Claude Code — VictoryChart, VictoryLine, VictoryBar, and VictoryScatter for web and React Native, VictoryPie for donut charts, VictoryArea for stacked areas, VictoryAxis for custom axes, VictoryTooltip and VictoryVoronoiContainer for hover tooltips, VictoryBrushContainer for range selection, VictoryZoomContainer for pan and zoom, VictoryLegend for series labels, custom theme with VictoryTheme, and VictoryStack for grouped bars.

6 min read Jun 25, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free