React with Remix
Remix is a full-stack React framework built around a simple bet: the web platform already solved most of the problems frameworks reinvent. Instead of layering a proprietary data layer over React, Remix leans on URLs, HTTP, forms, and fetch so your app keeps working even before — or without — JavaScript. As of v7, Remix has merged into React Router framework mode, meaning the routing library most React apps already use now ships loaders, actions, and SSR out of the box. The result is fast, server-rendered pages that progressively enhance into a snappy SPA.
Nested routes and the URL as state
In Remix, routes are nested both in the URL and in the UI. A route like /sales/invoices/123 maps to a chain of layout components, each owning its own slice of the screen and its own data. Parent layouts render an <Outlet /> where the matching child appears, so a sidebar, a list, and a detail pane can each load independently and persist across navigations.
// app/routes.ts — route config in React Router framework mode
import { type RouteConfig, route, index } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("invoices", "routes/invoices.tsx", [
index("routes/invoices.index.tsx"),
route(":id", "routes/invoices.detail.tsx"),
]),
] satisfies RouteConfig;
Each segment is its own module that can declare a loader (for reads) and an action (for writes), so data fetching is co-located with the UI that needs it.
Loaders: data on the server, before render
A loader runs on the server for the route being requested. Remix calls every matching route’s loader in parallel, waits for them, and renders the full HTML with data already in place — no client-side fetch waterfall, no loading spinners on first paint. The component reads that data with useLoaderData.
// app/routes/invoices.detail.tsx
import type { Route } from "./+types/invoices.detail";
import { useLoaderData } from "react-router";
export async function loader({ params }: Route.LoaderArgs) {
const res = await fetch(`https://api.example.com/invoices/${params.id}`);
if (!res.ok) throw new Response("Not found", { status: 404 });
return { invoice: await res.json() };
}
export default function InvoiceDetail() {
const { invoice } = useLoaderData<typeof loader>();
return (
<article>
<h2>Invoice #{invoice.number}</h2>
<p>Total: ${invoice.total}</p>
</article>
);
}
Because loaders run only on the server, secrets and database clients never reach the bundle. Throwing a Response from a loader is caught by the nearest error boundary, giving you real HTTP status codes for free.
Actions and progressive enhancement
Mutations go through <Form> and an action. The <Form> component renders a plain HTML <form>; when JavaScript hasn’t loaded yet, the browser submits it the native way and Remix handles the POST on the server. Once hydrated, Remix intercepts the submit, posts via fetch, runs the action, and automatically re-runs the page’s loaders to refresh the UI.
// app/routes/invoices.detail.tsx (continued)
import { Form, redirect } from "react-router";
export async function action({ request, params }: Route.ActionArgs) {
const form = await request.formData();
await fetch(`https://api.example.com/invoices/${params.id}/pay`, {
method: "POST",
body: JSON.stringify({ note: form.get("note") }),
headers: { "Content-Type": "application/json" },
});
return redirect(`/invoices/${params.id}`);
}
export function PayForm() {
return (
<Form method="post">
<input name="note" placeholder="Payment note" />
<button type="submit">Mark as paid</button>
</Form>
);
}
This is the heart of Remix’s philosophy: the app is functional with zero client JavaScript, then enhanced. Use useNavigation to show pending states and useFetcher for non-navigating mutations (like an inline “favorite” toggle) that don’t change the URL.
Tip: A submit button that works without JS is your baseline. Layer optimistic UI on top with
useFetcher().formData— never make the happy path depend on hydration finishing first.
Remix vs Next.js
Both are excellent production frameworks, but they make different architectural bets. Next.js centers on React Server Components and a layered caching model; Remix centers on web standards (Request/Response, FormData, fetch) and a thinner abstraction you can reason about.
| Remix (React Router) | Next.js (App Router) | |
|---|---|---|
| Core model | Loaders / actions + <Form> | Server Components + Server Actions |
| Data fetching | Parallel route loaders | async components, fetch cache |
| Mutations | HTML form actions | Server Actions |
| Rendering | SSR-first, optional prerender | RSC streaming, SSG, ISR |
| Mental model | Web standards (Request/Response) | React-centric, framework caching |
| Deploys anywhere | Yes (adapters for any runtime) | Best on Vercel; portable with effort |
Remix tends to win when you value progressive enhancement, runtime portability, and a small conceptual surface. Next.js tends to win when you want deep RSC integration, fine-grained caching, and the broadest ecosystem of hosted features.
Best Practices
- Co-locate
loader,action, and the component in one route module so data and UI evolve together. - Let loaders run in parallel — avoid
await-ing one fetch before starting the next within a single route. - Always use
<Form>and actions for mutations so the page works before JavaScript loads, then enhance. - Throw
Responseobjects from loaders/actions to drive real HTTP status codes and error boundaries. - Reach for
useFetcherwhen a mutation should not change the URL (toggles, inline edits, polling). - Show pending UI with
useNavigationand optimistic UI with fetcher data to keep interactions snappy. - Keep secrets in loaders/actions only — anything in a default-exported component ships to the browser.