Guide

Remix fundamentals explained

A warehouse team needs to update purchase orders without waiting for a JavaScript bundle to hydrate. The form should work when JavaScript fails, load data before paint, and show validation errors inline — all from one codebase. Remix (now the framework layer of React Router v7) is a full-stack React framework built around that constraint: every route exports a loader for reads and an action for writes, both running on the server with Web Standard Request and Response objects. Navigation triggers parallel loader calls; HTML forms POST to actions with progressive enhancement — no useEffect fetch on mount required. Since Remix 2, the build pipeline runs on Vite for fast HMR and lean production bundles. This guide covers route modules, nested layouts and outlets, error boundaries, caching headers, deployment targets, a Harbor Supply order portal worked example, how Remix compares to Next.js and plain React, a framework decision table, common pitfalls, and a production checklist.

What Remix adds on top of React

React answers component composition and client interactivity. Remix answers how data enters those components and how user mutations reach the server. Instead of scattering fetch calls in useEffect hooks or building a separate REST API with ad hoc DTOs, Remix colocates server logic in route files next to the UI that consumes it.

Core ideas that distinguish Remix from a client-only SPA:

  • Loaders — async functions that run on the server (or at build time for static routes) before a route renders; return JSON serialized into the HTML document.
  • Actions — async functions that handle POST, PUT, PATCH, and DELETE from HTML forms or fetcher calls; return redirects, validation errors, or updated data.
  • Nested routing — parent routes render persistent layout chrome; child routes render into an <Outlet /> without remounting the parent.
  • Progressive enhancement — forms work without JavaScript; Remix enhances them client-side for optimistic UI and transitions.
  • Web Standards — loaders and actions receive a Request and return Response (or plain objects Remix serializes); no proprietary request context.

Remix does not replace React patterns you already know — hooks, context, and component libraries still apply inside route modules. What changes is the data boundary: server-only code (database queries, secret API keys) stays in loaders and actions, never shipped to the browser bundle.

Route modules and file conventions

Remix uses file-based routing under app/routes/. A file named orders.$orderId.tsx maps to /orders/:orderId. Dots separate URL segments; dollar signs prefix dynamic params. Index routes use _index.tsx or orders._index.tsx for list views at a parent path.

A typical route module exports four concerns:

// app/routes/orders.$orderId.tsx
import type { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import { useLoaderData, Form } from '@remix-run/react';

export async function loader({ params, request }: LoaderFunctionArgs) {
  const order = await db.orders.find(params.orderId);
  if (!order) throw new Response('Not Found', { status: 404 });
  return json({ order });
}

export async function action({ request, params }: ActionFunctionArgs) {
  const form = await request.formData();
  const status = form.get('status');
  await db.orders.update(params.orderId!, { status });
  return redirect(`/orders/${params.orderId}`);
}

export default function OrderDetail() {
  const { order } = useLoaderData<typeof loader>();
  return (
    <Form method="post">
      <h1>Order {order.id}</h1>
      <select name="status" defaultValue={order.status}>
        <option value="pending">Pending</option>
        <option value="shipped">Shipped</option>
      </select>
      <button type="submit">Update</button>
    </Form>
  );
}

Layouts and nested routes

Parent routes without a trailing . in the filename wrap children. orders.tsx might render a sidebar and <Outlet />; orders.$orderId.tsx fills the outlet when the URL matches. Loaders on parent and child routes run in parallel during navigation — Remix waits for all before rendering, so you never flash a layout with empty child content.

Error and boundary handling

Export ErrorBoundary to catch loader/action throws and render fallback UI per route. Throwing new Response('Unauthorized', { status: 401 }) from a loader produces the correct HTTP status and boundary UI. This is cleaner than try/catch in every component or global error middleware that loses route context.

Loaders: data fetching without useEffect

A loader runs on the server for every document request and client-side navigation to its route. It can read cookies, call databases, and hit internal APIs with secrets that never reach the client. The return value is serialized into the HTML response and rehydrated via useLoaderData().

Important loader behaviors:

  • Parallel execution — all matching route loaders fetch concurrently; slow child loaders do not block parent data unnecessarily beyond the render barrier.
  • Revalidation — after an action completes, Remix revalidates loaders on active routes by default so lists refresh after mutations.
  • Cache-Control — return json(data, { headers: { 'Cache-Control': 'public, max-age=60' } }) for CDN caching of public pages; use private, no-store for authenticated views.
  • Deferred datadefer() streams slow promises so fast shell HTML paints while heavy queries finish (similar in spirit to React Suspense on the server).

Contrast with Next.js App Router: Next colocates async server components that fetch inline; Remix centralizes fetch in exported loader functions called by the framework router. Both avoid client-side waterfalls; Remix’s model maps closely to REST resource boundaries and is easier to unit-test by calling loader({ request, params, context }) directly in Vitest without rendering React trees.

Actions: forms as the mutation primitive

Remix treats HTML forms as first-class. An <Form method="post"> submits to the nearest route’s action function. Without JavaScript, the browser performs a full document navigation; with JavaScript, Remix intercepts the submit, calls the action via fetch, and revalidates loaders — same code path, enhanced UX.

Validation pattern: return json({ errors: { email: 'Invalid' } }, { status: 400 }) from the action; read via useActionData() in the component. Field-level errors display next to inputs; the form retains user input through defaultValue or controlled state seeded from useNavigation().formData during submission.

For mutations that should not navigate away (toggle favorite, inline edit), use useFetcher(). It submits to an action without changing the URL or remounting the page — still progressive-enhancement friendly when wired through fetcher.Form.

export async function action({ request }: ActionFunctionArgs) {
  const form = await request.formData();
  const intent = form.get('intent');
  if (intent === 'archive') {
    await archiveOrder(form.get('orderId') as string);
    return json({ ok: true });
  }
  return json({ error: 'unknown_intent' }, { status: 400 });
}

Multiple buttons on one form distinguish intent via name="intent" values or separate forms per action. Avoid REST-style PUT from plain HTML without JavaScript — HTML forms only support GET and POST; use _method hidden fields or separate routes if you need semantic HTTP verbs behind a proxy.

Vite, build pipeline, and deployment

Modern Remix projects use the Vite plugin (@remix-run/dev with vite.config.ts). Development gets instant HMR; production builds emit a server bundle plus client assets. The server entry handles Node requests; static assets serve from public/build/ or a CDN.

Deployment targets:

  • Node adapter (@remix-run/node) — Express or standalone Node server behind nginx; fits VPS and Kubernetes like Harbor’s stack.
  • Cloudflare Pages/Workers (@remix-run/cloudflare) — edge SSR with D1/KV bindings; loaders run close to users.
  • Vercel / Netlify adapters — serverless functions per request; watch cold starts on loader-heavy pages.
  • SPA mode — optional client-only build when SSR is not required; loses loader SSR benefits but keeps routing conventions.

Environment variables: prefix client-exposed values with VITE_ (or Remix’s PUBLIC_ convention depending on version); keep database URLs server-only. Validate env at boot in entry.server.tsx so misconfiguration fails deploy, not first user request.

Worked example: Harbor Supply order portal

Harbor Supply runs an internal order portal on Remix with a Node adapter behind nginx. The design optimizes for form-heavy workflows and reliable data freshness:

  • Route tree: orders.tsx (layout + nav) → orders._index.tsx (filterable list) → orders.$orderId.tsx (detail + status form) → orders.$orderId.notes.tsx (nested notes panel via outlet).
  • List loader: reads url.searchParams for status filter and page cursor; queries PostgreSQL with indexed status column; returns json({ orders, nextCursor }) in under 40 ms p95.
  • Detail loader: fetches order header, line items, and audit log in parallel with Promise.all; throws 404 Response if missing.
  • Status action: validates CSRF token from session, checks role in loader-cached permissions, updates row, appends audit entry, returns redirect with flash message in session cookie.
  • Notes fetcher: useFetcher posts quick notes without leaving the detail page; revalidation refreshes the notes outlet only.
  • Progressive enhancement: warehouse tablets on flaky Wi-Fi can still change status via native form submit; JavaScript adds inline validation and disables double-submit.

Tests call loader and action with synthetic Request objects — no browser required. E2E uses Playwright for full navigation flows. The same patterns appear in our software testing guide: unit-test server functions, integration-test routes, E2E sparingly for critical paths.

Framework decision table

Choose Remix when… Prefer Next.js when… Prefer plain React SPA when…
Forms and mutations dominate the product Static marketing + ISR content mix is primary App is behind auth with no SEO needs
Progressive enhancement is a hard requirement React Server Components streaming is the goal Team has no server runtime or ops capacity
You want loaders/actions testable without React Vercel deployment and image optimization are defaults Backend is a separate team-owned OpenAPI service
Nested dashboards with persistent layout chrome Middleware edge auth and geo routing matter Real-time WebSocket state drives the entire UI
React Router mental model and ecosystem alignment Large Contentful/headless CMS integration Bundle is static on CDN with no SSR complexity

Many teams use both: Next.js for public marketing, Remix for authenticated app shells. Shared TypeScript types and design tokens cross projects; do not force one framework where its strengths do not apply.

Common pitfalls

  • Fetching in useEffect anyway — duplicates loader data, causes hydration mismatch; put reads in loaders.
  • Returning secrets from loaders — everything in useLoaderData is visible in page source; strip internal fields before json().
  • Disabling revalidation blindlyshouldRevalidate returning false everywhere shows stale lists after mutations.
  • GIANT monolithic actions — one action with twelve intents becomes unmaintainable; split routes or use explicit intent switches with shared validators.
  • Ignoring CSRF on cookie-auth forms — same-origin policy helps but double-submit tokens matter for session cookies.
  • Assuming loaders run only once — client navigations re-run loaders; idempotent reads required; use HTTP caching headers for expensive public data.
  • Shipping server code to client — importing db in components bundles secrets; keep imports in loader/action files only.

Production checklist

  • Route modules export typed loaders and actions; no raw fetch in useEffect for initial data.
  • Error boundaries on routes that call external services; 404/401 thrown as Response objects.
  • Forms use method="post" with server-side validation and useActionData error display.
  • CSRF or SameSite=strict cookies on mutating actions with session auth.
  • Cache-Control headers set per route sensitivity (public vs private).
  • Environment variables validated at server boot; secrets never in client bundle.
  • Loader/action unit tests with synthetic Request objects.
  • CDN or nginx gzip/brotli for static build/ assets with long cache fingerprints.
  • Health check route (/health) that does not hit the database or times out fast.
  • Migration plan documented if upgrading React Router v7 unified packages.

Key takeaways

  • Remix colocates server data logic in route loaders and actions alongside React UI.
  • HTML forms are the default mutation path with progressive enhancement built in.
  • Nested routes compose layouts via outlets; parallel loaders eliminate fetch waterfalls.
  • Pair Remix with Vite for dev speed and lean production builds.
  • Choose Remix for form-heavy apps; choose Next.js when RSC/ISR content patterns dominate.

Related reading