frontend-remix-patterns

star 8

Use this skill when the user says 'Remix pattern', 'Remix form validation', 'Remix optimistic UI', 'Remix error boundary', 'Remix SEO', 'Remix caching', 'Remix PWA'. This skill enforces: server-side form validation with Zod, route-level error boundaries, meta exports for SEO, Cache-Control strategies, optimistic updates with useFetcher, and service workers for offline support. Requires existing Remix project (package.json with @remix-run/*). Do NOT use for: React-only validation, client-side-only forms.

j4flmao By j4flmao schedule Updated 6/1/2026

name: frontend-remix-patterns description: > Use this skill when the user says 'Remix pattern', 'Remix form validation', 'Remix optimistic UI', 'Remix error boundary', 'Remix SEO', 'Remix caching', 'Remix PWA'. This skill enforces: server-side form validation with Zod, route-level error boundaries, meta exports for SEO, Cache-Control strategies, optimistic updates with useFetcher, and service workers for offline support. Requires existing Remix project (package.json with @remix-run/*). Do NOT use for: React-only validation, client-side-only forms. version: "1.0.0" author: "j4flmao" license: "MIT" compatibility: claude-code: true cursor: true codex: true windsurf: true tags: [frontend, remix, react, patterns, phase-7]

Remix Patterns

Purpose

Apply production-grade patterns to Remix applications: server validation, error boundaries per route, SEO metadata, caching, optimistic UI, and PWA support.

Agent Protocol

Trigger

Exact user phrases: "Remix pattern", "Remix form validation", "Remix optimistic UI", "Remix error boundary", "Remix SEO", "Remix caching", "Remix PWA".

Input Context

Before activating, verify:

  • Remix project with @remix-run/react and a routing structure.
  • Whether Zod is already installed for validation.
  • Existing meta/SEO setup.
  • Target deployment platform for caching strategy.

Output Artifact

No file output. Produces code patterns for validation, error handling, SEO, caching, optimistic updates, and PWA.

Response Format

Code examples only. Show action/loader with validation, error boundary, meta export, cache headers.

No preamble. No postamble. No explanations. No filler/hedging/transitions. Compress output — why use many token when few do trick.

Completion Criteria

  • Form validation uses Zod schema in action, returns field errors as JSON.
  • Client-side enhancement shows errors inline from useActionData.
  • ErrorBoundary per route with root fallback.
  • meta function per route with og, twitter, canonical.
  • Cache-Control headers set on loader responses.
  • Optimistic updates use useFetcher with local state + rollback.
  • Service worker registered from public dir with offline fallback.

Max Response Length

Code: 15 lines per example. Unlimited patterns.

Component Architecture / Decision Trees

Architecture Options

Approach Trade-off When to Use
Server-side validation only Simpler, no JS needed for form to work Basic forms, login, signup
Server + client validation with useActionData Better UX, instant feedback Forms with many fields
Optimistic UI with useFetcher Instant feedback, rollback on error Likes, stars, add to cart
Route ErrorBoundary Scoped error handling Per-route error recovery
Root ErrorBoundary Global fallback Unexpected errors, network failures
Meta per route SEO per page Blog posts, product pages
Resource route sitemap Dynamic sitemap generation Sites with dynamic content

Decision Tree: Form Validation

Is the form simple (email + password)?
  ├── Yes -> Server validation with Zod
  └── No (many fields, complex rules) ->
       ├── Server validation (required)
       └── + Client enhancement with useActionData

Decision Tree: Optimistic UI

Does the mutation need instant feedback?
  ├── No -> Standard <Form> with pending state
  └── Yes -> useFetcher + local state
       ├── Can you easily rollback? -> Optimistic update
       └── Risk of race conditions? -> Use useFetcher data for confirmation

Decision Tree: Error Boundary Placement

Is this an expected error (404, 403)?
  ├── Yes -> throw Response in loader, use CatchBoundary (v1) or ErrorBoundary (v2)
  └── No -> ErrorBoundary for unexpected errors
       ├── Route-level -> Scoped error recovery
       └── Root-level -> Global fallback in root.tsx

Decision Tree: Caching Strategy

Is the data user-specific?
  ├── Yes -> private, no-store (never cache)
  └── No -> Is it frequently updated?
       ├── Yes -> max-age=60, s-maxage=300
       └── No -> max-age=3600, s-maxage=86400

Decision Tree: PWA vs Standard

Does the app need offline support?
  ├── No -> Skip service worker, standard web app
  └── Yes -> Service worker with cache-first strategy
       ├── Full offline -> Cache all routes on first visit
       └── Partial offline -> Cache static assets only

Component Design Patterns

Multi-Intent Action

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData()
  const intent = formData.get('intent')

  switch (intent) {
    case 'create': {
      const result = createSchema.safeParse(Object.fromEntries(formData))
      if (!result.success) return json({ errors: result.error.flatten().fieldErrors, intent }, { status: 400 })
      await db.post.create({ data: result.data })
      return redirect('/posts')
    }
    case 'delete': {
      const id = formData.get('id')
      await db.post.delete({ where: { id: String(id) } })
      return json({ ok: true })
    }
    case 'toggle-pin': {
      const id = formData.get('id')
      const post = await db.post.findUnique({ where: { id: String(id) } })
      await db.post.update({ where: { id: String(id) }, data: { pinned: !post?.pinned } })
      return json({ ok: true })
    }
    default:
      throw new Response('Invalid intent', { status: 400 })
  }
}

Sitemap Resource Route

// app/routes/sitemap[.]xml.tsx
import { generateSitemap } from '@remix-run/sitemap'
import { db } from '~/db'

export async function loader({ request }: LoaderFunctionArgs) {
  const posts = await db.post.findMany({ select: { slug: true, updatedAt: true } })
  const postEntries = posts.map(p => ({
    route: `/blog/${p.slug}`,
    lastmod: p.updatedAt.toISOString(),
    changefreq: 'weekly' as const,
    priority: 0.7,
  }))

  return generateSitemap(request, [
    { route: '/', priority: 1.0 },
    { route: '/about', priority: 0.5 },
    ...postEntries,
  ])
}

Meta with Dynamic Data

export const meta: MetaFunction<typeof loader> = ({ data, params, location }) => {
  const product = data?.product
  if (!product) return [{ title: 'Product Not Found' }]

  return [
    { title: product.name },
    { name: 'description', content: product.description?.slice(0, 160) },
    { property: 'og:title', content: product.name },
    { property: 'og:description', content: product.description?.slice(0, 160) },
    { property: 'og:image', content: product.image },
    { property: 'og:url', content: `https://example.com${location.pathname}` },
    { name: 'twitter:card', content: 'summary_large_image' },
    { name: 'twitter:title', content: product.name },
    { tagName: 'link', rel: 'canonical', href: `https://example.com${location.pathname}` },
    { script: [{ type: 'application/ld+json', children: JSON.stringify(productSchema(product)) }] },
  ]
}

Robots.txt Resource Route

// app/routes/robots[.]txt.tsx
export function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url)
  return new Response(
    `User-agent: *\nAllow: /\nSitemap: ${url.origin}/sitemap.xml\nDisallow: /admin`,
    { headers: { 'Content-Type': 'text/plain' } }
  )
}

Scroll Restoration Pattern

// app/root.tsx
import { ScrollRestoration } from '@remix-run/react'

export default function Root() {
  return (
    <html>
      <head />
      <body>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  )
}

Pending UI with useNavigation

function GlobalPendingIndicator() {
  const navigation = useNavigation()
  const isPending = navigation.state !== 'idle'

  return (
    <div
      style={{
        position: 'fixed',
        top: 0,
        left: 0,
        right: 0,
        height: 3,
        background: isPending ? 'blue' : 'transparent',
        transition: 'background 0.3s',
        zIndex: 9999,
      }}
    />
  )
}

State Management Patterns

In Remix, most state lives on the server. The patterns below handle the limited client state needed.

Loader Data as State Source

The primary state management pattern: loaders return data, components consume it:

export async function loader({ request }: LoaderFunctionArgs) {
  const userId = await getUserId(request)
  const [profile, notifications, posts] = await Promise.all([
    db.profile.findUnique({ where: { userId } }),
    db.notification.findMany({ where: { userId, read: false } }),
    db.post.findMany({ where: { authorId: userId }, orderBy: { createdAt: 'desc' } }),
  ])
  return json({ profile, notifications, posts })
}

export default function Dashboard() {
  const { profile, notifications, posts } = useLoaderData<typeof loader>()
  return (/* render */)
}

URL Search Params for Filter/Sort/Pagination

export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url)
  const filters = {
    search: url.searchParams.get('q') || '',
    category: url.searchParams.get('category') || '',
    sort: url.searchParams.get('sort') || 'date',
    page: Number(url.searchParams.get('page')) || 1,
  }
  // ...
  return json({ products, filters })
}

export default function Products() {
  const { filters } = useLoaderData<typeof loader>()
  const [searchParams, setSearchParams] = useSearchParams()

  return (
    <select
      value={filters.sort}
      onChange={(e) => setSearchParams(prev => {
        prev.set('sort', e.target.value)
        return prev
      })}
    >
      <option value="date">Newest</option>
      <option value="price">Price</option>
    </select>
  )
}

Session Flash Messages

export async function action({ request }: ActionFunctionArgs) {
  const session = await getSession(request.headers.get('Cookie'))
  try {
    await doSomething()
    session.flash('success', 'Done!')
    return redirect('/success', {
      headers: { 'Set-Cookie': await commitSession(session) },
    })
  } catch (e) {
    session.flash('error', 'Failed')
    return redirect('/error', {
      headers: { 'Set-Cookie': await commitSession(session) },
    })
  }
}

// In route component:
export async function loader({ request }: LoaderFunctionArgs) {
  const session = await getSession(request.headers.get('Cookie'))
  return json({
    flash: session.get('success') || session.get('error') || null,
  }, {
    headers: { 'Set-Cookie': await commitSession(session) },
  })
}

useFetcher for Non-Navigation State

function NotificationBell() {
  const fetcher = useFetcher()
  const [count, setCount] = useState(0)

  useEffect(() => {
    if (fetcher.data?.count !== undefined) setCount(fetcher.data.count)
  }, [fetcher.data])

  return (
    <button onClick={() => fetcher.load('/api/notifications/count')}>
      {count} notifications
    </button>
  )
}

Performance Optimization

Server Validation Cost

Zod validation on every action has a cost. For very large forms, consider parsing with .safeParseAsync() and using z.object({...}).parse() only on required fields. Schemas with 20+ fields should be optimized with .strict() to reject unexpected fields.

Cache Strategy

Cache Header Effect
public, max-age=300 Browser caches for 5 minutes
s-maxage=3600 CDN caches for 1 hour
stale-while-revalidate=60 Serves stale for 60s while refetching
private, no-store Never cache (auth routes)

Optimistic UI Performance

Optimistic updates should be lightweight DOM-only changes. Avoid recalculating lists or triggering expensive operations in the optimistic callback.

Error Boundary Cost

ErrorBoundary components are included in the route's client bundle. They are small (1-2KB) but should not contain heavy UI libraries.

Link Prefetching

<Link prefetch="intent" to="/products">Products</Link>     // prefetch on hover/touch
<Link prefetch="render" to="/dashboard">Dashboard</Link>    // prefetch when rendered
<Link prefetch="viewport" to="/contact">Contact</Link>      // prefetch when in viewport
<Link prefetch="none" to="/logout">Logout</Link>            // never prefetch

Build & Bundle Considerations

Build Configuration

// vite.config.ts
import { vitePlugin as remix } from '@remix-run/dev'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [remix()],
  build: {
    target: 'es2022',
    sourcemap: false,
    rollupOptions: {
      output: {
        manualChunks: (id) => {
          if (id.includes('node_modules')) return 'vendor'
        },
      },
    },
  },
  server: { port: 3000 },
})

Build Output

build/
  client/       -- Client bundles (JS, CSS, assets)
  server/       -- Server bundle with loaders/actions

Run with: remix-serve build/server/index.js

CSS Strategy

  • Route-level CSS via links export for automatic code splitting
  • Global CSS in app/root.tsx via links export
  • Tailwind: install @tailwindcss/vite plugin, import in root CSS
  • CSS Modules: name files *.module.css, import as import styles from './styles.module.css'

Environment Variables

// Server-only (in loaders/actions): process.env.DATABASE_URL
// Client-exposed: pass through loader
export async function loader() {
  return json({ publicKey: process.env.PUBLIC_STRIPE_KEY })
}

Testing Strategies

Testing Validation Schemas

// __tests__/schemas.test.ts
import { describe, it, expect } from 'vitest'
import { z } from 'zod'

const createProductSchema = z.object({
  name: z.string().min(1, 'Name required'),
  price: z.coerce.number().positive(),
  category: z.enum(['electronics', 'clothing', 'food']),
})

describe('createProductSchema', () => {
  it('accepts valid product data', () => {
    const result = createProductSchema.parse({ name: 'Widget', price: '10', category: 'electronics' })
    expect(result.price).toBe(10)
  })

  it('rejects missing name', () => {
    expect(() => createProductSchema.parse({ price: '10', category: 'electronics' }))
      .toThrow('Name required')
  })

  it('rejects negative price', () => {
    expect(() => createProductSchema.parse({ name: 'Widget', price: '-5', category: 'electronics' }))
      .toThrow()
  })
})

Testing Actions with FormData

// __tests__/settings.action.test.ts
import { describe, it, expect } from 'vitest'
import { action } from '../app/routes/settings'

describe('settings action', () => {
  it('validates name length', async () => {
    const formData = new FormData()
    formData.set('name', 'A')
    formData.set('email', 'test@test.com')

    const response = await action({
      request: new Request('http://localhost/settings', { method: 'POST', body: formData }),
      params: {},
      context: {},
    })

    expect(response.status).toBe(400)
    const data = await response.json()
    expect(data.errors.name).toBeDefined()
  })

  it('redirects on success', async () => {
    const formData = new FormData()
    formData.set('name', 'John')
    formData.set('email', 'john@test.com')

    const response = await action({ /* ... same pattern */ })
    expect(response.status).toBe(302)
    expect(response.headers.get('Location')).toBe('/settings')
  })
})

Testing Error Boundaries

// __tests__/ErrorBoundary.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { ErrorBoundary } from '../app/routes/products.$id'

describe('Product ErrorBoundary', () => {
  it('renders error message', () => {
    render(<ErrorBoundary error={new Error('Product not found')} />)
    expect(screen.getByText('Product not found')).toBeDefined()
  })
})

E2E Form Testing

// e2e/contact.spec.ts
import { test, expect } from '@playwright/test'

test('submits contact form without JS', async ({ page }) => {
  await page.goto('/contact')
  await page.fill('[name="email"]', 'test@test.com')
  await page.fill('[name="message"]', 'Hello')
  await page.click('button[type="submit"]')
  await expect(page).toHaveURL(/\/thanks/)
})

test('shows validation errors', async ({ page }) => {
  await page.goto('/contact')
  await page.click('button[type="submit"]')
  await expect(page.locator('[aria-invalid="true"]')).toHaveCount(2)
})

Migration Patterns

Express API to Remix Resource Route

// Before: Express
app.get('/api/products', async (req, res) => {
  const products = await db.product.findMany()
  res.json(products)
})

// After: Remix resource route
export async function loader() {
  return json(await db.product.findMany())
}

React Router SPA to Remix

// Before: SPA with client data fetching
function ProductPage() {
  const { id } = useParams()
  const [product, setProduct] = useState(null)
  useEffect(() => { fetch(`/api/products/${id}`).then(r => r.json()).then(setProduct) }, [id])
  if (!product) return <Spinner />
  return <ProductDetail product={product} />
}

// After: Remix
export async function loader({ params }) {
  const product = await db.product.findUnique({ where: { id: params.id } })
  if (!product) throw new Response(null, { status: 404 })
  return json(product)
}
export default function ProductPage() {
  const product = useLoaderData<typeof loader>()
  return <ProductDetail product={product} />
}

Form Handling Migration

// Before: Client-side fetch
async function handleSubmit(e: React.FormEvent) {
  e.preventDefault()
  const res = await fetch('/api/contact', {
    method: 'POST',
    body: new FormData(e.currentTarget as HTMLFormElement),
  })
  if (res.ok) navigate('/thanks')
}

// After: Remix
export async function action({ request }) {
  const formData = await request.formData()
  // validate and process
  return redirect('/thanks')
}
// Component: <Form method="post">...</Form>

Anti-Patterns

Client-Side Validation Only

// Anti-pattern: validation only on client
function handleSubmit(e) {
  e.preventDefault()
  if (!email.includes('@')) return setError('Invalid email')
  fetch('/api/contact', { method: 'POST', body: new FormData(e.target) })
}

// Correct: validate on server always
export async function action({ request }) {
  const result = schema.safeParse(formData)
  if (!result.success) return json({ errors: result.error.flatten().fieldErrors }, { status: 400 })
}

Loading Data in useEffect

// Anti-pattern: client-side fetch in component
useEffect(() => { fetch('/api/products').then(r => r.json()).then(setProducts) }, [])

// Correct: loader
export async function loader() { return json(await db.product.findMany()) }

Not Returning Proper Status Codes

// Anti-pattern: returning 200 with error
return json({ error: 'Not found' }) // status 200

// Correct
throw new Response('Not found', { status: 404 })

Mixing Client and Server Routes

Don't have overlapping route patterns between Remix routes and external API routes. If /api/products is a Remix resource route, don't also proxy to an external API at the same path.

One Giant Meta Function

// Anti-pattern: all meta in one route
// meta should be per-route, specific to the page content

Missing Focus Management

After form submission with errors, focus should move to the first invalid field. Remix doesn't do this automatically:

useEffect(() => {
  if (actionData?.errors) {
    const firstError = document.querySelector('[aria-invalid="true"]')
    if (firstError instanceof HTMLElement) firstError.focus()
  }
}, [actionData])

Overusing useFetcher for Navigation Mutations

useFetcher does not update the URL. For mutations that should change the URL (create, delete that redirects), use <Form> instead.

Common Pitfalls

Pitfall 1: Client-Only Validation

Client validation is an enhancement. Server validation is mandatory. Always validate in the action.

Pitfall 2: Not Using useActionData for Errors

Returning validation errors without useActionData forces a full page reload and no inline error display. Always return errors as JSON with 4xx status.

Pitfall 3: Caching Authenticated Routes

Never Cache-Control authenticated routes. Use private, no-store or omit the header.

Pitfall 4: One Big Action Function

Using a single action with intent field is cleaner than multiple routes. Wrap in a switch statement for readability.

Pitfall 5: Missing Error Boundaries

Without route-level ErrorBoundary, an error in one component crashes the entire page. Add ErrorBoundary to every layout route at minimum.

Pitfall 6: Not Handling useFetcher Idle State

useFetcher has idle, loading, and submitting states. Check fetcher.state before displaying data to avoid showing stale or undefined values.

Compared With

Remix Form Validation vs React Hook Form

Remix validates on the server natively; React Hook Form is client-first. Remix works without JS; React Hook Form requires JS. For Remix projects, Zod + server validation is the idiomatic approach.

Remix Optimistic UI vs TanStack Query

Both support optimistic updates. Remix's useFetcher approach is simpler (form-based) but less flexible. TanStack Query has richer cache invalidation and retry logic but requires more setup.

Remix Meta vs Next.js Metadata API

Remix's meta export is a function that receives loader data, making it truly dynamic per request. Next.js's generateMetadata is similar but Remix's approach is more explicit about the data dependency.

Ecosystem & Tooling

Core Libraries

Library Purpose
zod Schema validation for actions
remix-validated-form Declarative form validation
@remix-run/node Session storage, cookie management
@remix-run/react Client hooks (useActionData, useFetcher)

SEO Tools

Tool Purpose
@remix-run/sitemap Sitemap generation
robots.txt resource route Crawler directives
JSON-LD in