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, andDELETEfrom HTML forms orfetchercalls; 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
Requestand returnResponse(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; useprivate, no-storefor authenticated views. - Deferred data —
defer()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.searchParamsfor status filter and page cursor; queries PostgreSQL with indexedstatuscolumn; returnsjson({ 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
redirectwith flash message in session cookie. - Notes fetcher:
useFetcherposts 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
useLoaderDatais visible in page source; strip internal fields beforejson(). - Disabling revalidation blindly —
shouldRevalidatereturning false everywhere shows stale lists after mutations. - GIANT monolithic actions — one action with twelve intents becomes unmaintainable; split routes or use explicit
intentswitches 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
dbin components bundles secrets; keep imports in loader/action files only.
Production checklist
- Route modules export typed loaders and actions; no raw
fetchinuseEffectfor initial data. - Error boundaries on routes that call external services; 404/401 thrown as
Responseobjects. - Forms use
method="post"with server-side validation anduseActionDataerror display. - CSRF or SameSite=strict cookies on mutating actions with session auth.
Cache-Controlheaders set per route sensitivity (public vs private).- Environment variables validated at server boot; secrets never in client bundle.
- Loader/action unit tests with synthetic
Requestobjects. - 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
- React fundamentals explained — components, hooks, and state patterns
- Next.js fundamentals explained — App Router, Server Components, and caching
- Vite fundamentals explained — dev server, HMR, and production builds
- SSR, CSR, SSG, and ISR explained — rendering modes and when each fits