Remix: Building Full-Stack React Apps
You open a React dashboard and watch it load in stages. The layout renders, then a spinner, then the user fetch finishes and kicks off the stats fetch, which finishes and kicks off the notifications fetch. Every component has its own useEffect, its own loading flag, and its own copy of server data that goes stale the moment someone submits a form. Most of that code exists only to move data from the server into the UI and keep it in sync.
Remix, from the team behind React Router, attacks that problem by moving data loading and mutations back to the server, per route, using plain HTTP and HTML forms. This post covers setup, file-based routing, loaders, actions, nested layouts, error boundaries and meta tags, then compares Remix with Next.js and ends with where I think it fits and where it doesn't.
Shopify acquired Remix in 2022, and its Hydrogen storefront framework is built on it. Remix runs on several JavaScript runtimes through adapters: Node.js, Deno, Cloudflare Workers and AWS Lambda, among others.
How Remix Is Put Together
Under the hood, Remix is a compiler plus a server runtime wrapped around React Router. A Remix app is basically a React Router app with a server layer built in, so the way you think about client-side routes also maps onto how Remix handles server requests.
Almost everything you write in Remix falls into one of three pieces:
- Loaders: Server-side functions that fetch data for a route before rendering. Loaders for nested routes run in parallel, so you avoid the request waterfall common in client-rendered SPAs.
- Actions: Server-side functions that handle form submissions and mutations. For most apps they replace the client-side state you'd otherwise keep for server data.
- Route Components: React components that read data from loaders and trigger actions through HTML forms, the way forms have always worked on the web.
Remix routes data from server loaders directly into React components, with no client-side fetching
Installation and Setup
The official create-remix CLI scaffolds a starter project for you, so setup only takes a couple of commands.
Create a New Remix App
Run the create-remix CLI to scaffold your project. You'll be prompted to choose a deployment target (Node.js, Cloudflare, Vercel, etc.).
# Create a new Remix project
npx create-remix@latest my-remix-app
# Navigate into the project
cd my-remix-app
# Install dependencies (if not already done)
npm install
# Start the development server
npm run dev
Project Structure Overview
Remix follows a app/ directory convention. Routes live in app/routes/ and the root layout is in app/root.tsx.
my-remix-app/
├── app/
│ ├── root.tsx # Root layout (html, head, body)
│ ├── entry.client.tsx # Client-side hydration entry
│ ├── entry.server.tsx # Server-side rendering entry
│ └── routes/
│ ├── _index.tsx # Matches /
│ ├── about.tsx # Matches /about
│ ├── blog._index.tsx # Matches /blog
│ └── blog.$slug.tsx # Matches /blog/:slug
├── public/ # Static assets
├── remix.config.js # Remix configuration
└── package.json
Configure Your Root Layout
The app/root.tsx file wraps your entire application. It renders the <html>, <head>, and <body> tags, so you control the whole document.
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta /> {/* Renders meta tags from route loaders */}
<Links /> {/* Renders link tags (CSS, etc.) */}
</head>
<body>
<Outlet /> {/* Renders the matched route */}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
File-Based Routing in Remix
Routes in Remix v2 are flat files. Next.js nests folders to nest routes; Remix puts the nesting in the filename and uses dots as separators. You can see the whole route tree in one directory listing, though it gets long in bigger apps.
Route File Naming Convention
| File Name | URL Pattern | Description |
|---|---|---|
_index.tsx |
/ |
Home / index route |
about.tsx |
/about |
Static route |
blog.$slug.tsx |
/blog/:slug |
Dynamic segment with $ prefix |
blog._index.tsx |
/blog |
Index of a nested route layout |
($lang).tsx |
/ or /:lang |
Optional segment with () |
$.tsx |
/* |
Splat / catch-all route |
_auth.login.tsx |
/login |
Pathless layout (prefix with _) |
Prefixing a route segment with _ (e.g., _auth.login.tsx) creates a pathless layout route. It wraps child routes in a shared layout without adding a URL segment. I use it for grouping authenticated routes or marketing pages under a shared header/footer.
Remix route hierarchy: dots in flat filenames encode nested layouts
Loading Data with Loaders
A loader runs on the server before the route component renders. It can query a database, call an external API, or read anything else async. Each route in the matched layout stack has its own loader, and Remix runs them in parallel, so you don't get the fetch-then-fetch-again waterfall of a client-rendered React app.
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/node";
// Runs on the server; never shipped to the browser
export async function loader({ params }: LoaderFunctionArgs) {
const post = await db.post.findUnique({
where: { slug: params.slug },
include: { author: true, tags: true },
});
if (!post) {
throw new Response("Post Not Found", { status: 404 });
}
return json({ post });
}
// The component receives data through useLoaderData: no useEffect, no loading state
export default function BlogPost() {
const { post } = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
<p>By {post.author.name}</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
Parallel Data Loading
Take a dashboard with a layout, a main content area and a sidebar widget, each with its own loader. When the URL matches all of them, Remix starts all three requests at once instead of one after another.
// app/routes/dashboard.tsx: layout loader
export async function loader() {
const user = await getUser(); // Runs in parallel ⚡
return json({ user });
}
// app/routes/dashboard._index.tsx: child loader
export async function loader() {
const stats = await getDashboardStats(); // Runs in parallel ⚡
return json({ stats });
}
// app/routes/dashboard.sidebar.tsx: sibling loader
export async function loader() {
const notifications = await getNotifications(); // Runs in parallel ⚡
return json({ notifications });
}
// All three loaders fire simultaneously.
// Total wait time = slowest single loader, not sum of all.
Using useLoaderData<typeof loader>() gives you TypeScript inference from server to component, so you don't maintain separate API types. Remix infers the return type of your json() call. One catch: data is serialized as JSON on the way over, so a Date from the loader arrives in the component as a string, and the inferred type reflects that.
Loaders and actions are stripped from the client build, but other code in a route file can still drag server modules in. If you see errors like process is not defined in the browser console, or your database client shows up in the client bundle, move that code into a .server.ts file (for example app/db.server.ts). Remix won't bundle .server modules for the browser.
Form Actions and Mutations
Mutations go through action functions, which are server-side handlers for form submissions. It's the old web model on purpose: an HTML form POSTs to a URL, the server handles it and redirects. You don't write a client-side fetch or invalidate a cache by hand. After an action finishes, Remix re-runs the loaders of every route on the page by default, so the UI picks up fresh data. If that's too many requests on a heavy page, export shouldRevalidate from a route to opt it out.
The simplest action handles a form POST and redirects on success:
import { redirect } from "@remix-run/node";
import { Form } from "@remix-run/react";
import type { ActionFunctionArgs } from "@remix-run/node";
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const title = formData.get("title") as string;
const body = formData.get("body") as string;
await db.post.create({ data: { title, body } });
return redirect("/blog");
}
export default function NewPost() {
return (
<Form method="post">
<input name="title" placeholder="Post title" required />
<textarea name="body" placeholder="Content" rows={8} />
<button type="submit">Publish</button>
</Form>
);
}
Return errors from the action and display them in the component:
import { json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const errors: Record<string, string> = {};
if (!email.includes("@")) errors.email = "Invalid email address";
if (password.length < 8) errors.password = "Min 8 characters";
if (Object.keys(errors).length) {
return json({ errors }, { status: 400 });
}
await createUser({ email, password });
return redirect("/dashboard");
}
export default function Register() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<input name="email" type="email" />
{actionData?.errors?.email && (
<p className="error">{actionData.errors.email}</p>
)}
<input name="password" type="password" />
{actionData?.errors?.password && (
<p className="error">{actionData.errors.password}</p>
)}
<button type="submit">Register</button>
</Form>
);
}
useFetcher lets you submit to any route without navigating. It works well for inline edits, likes, or background saves:
import { useFetcher } from "@remix-run/react";
function LikeButton({ postId, liked }: { postId: string; liked: boolean }) {
const fetcher = useFetcher();
// Optimistically reflect the pending state
const isLiking = fetcher.state !== "idle";
return (
<fetcher.Form method="post" action="/api/like">
<input type="hidden" name="postId" value={postId} />
<input type="hidden" name="action" value={liked ? "unlike" : "like"} />
<button type="submit" disabled={isLiking}>
{liked ? "❤️ Liked" : "🤍 Like"}
</button>
</fetcher.Form>
);
}
Use useNavigation and useFetcher state to build optimistic updates:
import { useNavigation } from "@remix-run/react";
function SubmitButton() {
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const isLoading = navigation.state === "loading";
return (
<button
type="submit"
disabled={isSubmitting || isLoading}
>
{isSubmitting ? "Saving..." : isLoading ? "Redirecting..." : "Save"}
</button>
);
}
// For optimistic list updates using fetcher.formData
function TodoItem({ todo }: { todo: Todo }) {
const fetcher = useFetcher();
// Read the intent from the pending form submission
const isDeleting =
fetcher.state === "submitting" &&
fetcher.formData?.get("intent") === "delete";
return (
<li style={{ opacity: isDeleting ? 0.4 : 1 }}>
{todo.title}
<fetcher.Form method="post">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={todo.id} />
<button type="submit">Delete</button>
</fetcher.Form>
</li>
);
}
Nested Routes and Layouts
Nested routes are where Remix pays off for bigger UIs. When a URL matches multiple route files (e.g., /dashboard/settings matches both dashboard.tsx and dashboard.settings.tsx), Remix renders them in a nested layout tree. Each layout renders an <Outlet /> where its child route appears.
This matters most when the user moves between child routes. Remix only re-fetches and re-renders the parts of the layout that actually changed. If the user goes from /dashboard/settings to /dashboard/profile, the outer dashboard.tsx layout stays mounted and its loader doesn't run again. Only the inner content swaps out.
import { Outlet, NavLink } from "@remix-run/react";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader() {
const user = await getCurrentUser();
return json({ user });
}
export default function DashboardLayout() {
const { user } = useLoaderData<typeof loader>();
return (
<div className="dashboard">
<aside className="sidebar">
<p>Welcome, {user.name}</p>
<nav>
<NavLink to="/dashboard"
className={({ isActive }) => isActive ? "active" : ""}>
Overview
</NavLink>
<NavLink to="/dashboard/settings">Settings</NavLink>
<NavLink to="/dashboard/billing">Billing</NavLink>
</nav>
</aside>
<main>
<Outlet /> {/* Child route renders here */}
</main>
</div>
);
}
import { json, redirect } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
export async function loader({ request }: LoaderFunctionArgs) {
const user = await getCurrentUser(request);
return json({ user });
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const name = formData.get("name") as string;
const email = formData.get("email") as string;
await updateUser({ name, email });
return redirect("/dashboard/settings?saved=true");
}
export default function DashboardSettings() {
const { user } = useLoaderData<typeof loader>();
return (
<section>
<h2>Account Settings</h2>
<Form method="post">
<label>
Name
<input name="name" defaultValue={user.name} />
</label>
<label>
Email
<input name="email" type="email" defaultValue={user.email} />
</label>
<button type="submit">Save Changes</button>
</Form>
</section>
);
}
Error Handling in Remix
Error UI in Remix lives next to the route that can fail. By exporting an ErrorBoundary component from a route file, Remix will render it in place of that route when either the loader throws, the action throws, or the component throws. The rest of the app, parent layouts included, keeps rendering normally.
import { useRouteError, isRouteErrorResponse, Link } from "@remix-run/react";
export async function loader({ params }: LoaderFunctionArgs) {
const post = await db.post.findUnique({ where: { slug: params.slug } });
if (!post) {
// Throwing a Response is the idiomatic Remix way to trigger a 404
throw new Response("Post not found", { status: 404 });
}
return json({ post });
}
// This ErrorBoundary replaces ONLY the blog.$slug route segment
// The nav, sidebar, and footer layouts remain intact
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div className="error-page">
<h1>{error.status} - {error.statusText}</h1>
<p>{error.data}</p>
<Link to="/blog">← Back to Blog</Link>
</div>
);
}
// Unexpected runtime error
const message = error instanceof Error ? error.message : "Unknown error";
return (
<div className="error-page">
<h1>Something went wrong</h1>
<p>{message}</p>
</div>
);
}
If a route doesn't export an ErrorBoundary, errors bubble up to the nearest ancestor that does. Always export an ErrorBoundary from root.tsx as a global catch-all to prevent blank white screens in production.
Exporting Meta from Routes
Each route can export a meta function to define dynamic <title> and <meta> tags. Remix merges these across the route hierarchy, so you get per-page SEO tags without a third-party head library:
import type { MetaFunction } from "@remix-run/node";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data?.post) {
return [{ title: "Post Not Found | My Blog" }];
}
return [
{ title: `${data.post.title} | My Blog` },
{ name: "description", content: data.post.excerpt },
{ property: "og:title", content: data.post.title },
{ property: "og:image", content: data.post.coverImage },
];
};
Remix vs Next.js
Both are solid full-stack React frameworks. They just make different trade-offs:
| Feature | Remix | Next.js |
|---|---|---|
| Data Fetching | Loaders (server-only, parallel) | Server Components + fetch, RSC cache |
| Mutations | Actions via HTML forms | Server Actions (React 19+) |
| Routing | Flat file naming with dots | Nested folder structure (app/ dir) |
| Rendering | SSR by default; no SSG | SSR, SSG, ISR, RSC all available |
| Error Handling | Per-route ErrorBoundary exports |
error.tsx colocated files |
| Deployment | Any runtime (Node, Deno, CF Workers, Lambda) | Vercel-optimized; others work but less polished |
| Progressive Enhancement | First-class (forms work without JS) | Possible but not the default pattern |
| Static Sites | Limited (needs server) | Strong SSG and ISR support |
| Bundle Size | Smaller client bundle (less JS sent) | RSC reduces JS; can still grow large |
| Learning Curve | Moderate (new mental model) | Gentle (large community, many tutorials) |
When Should You Choose Remix?
Remix fits best in these cases:
- Form-heavy, interactive apps: CRUD apps, dashboards, admin panels and SaaS products map cleanly onto loaders and actions.
- Edge or non-Vercel deployments: Cloudflare Workers, Deno Deploy and AWS Lambda all have adapters, and none of them is a second-class target.
- Progressive enhancement by default: Remix forms still submit if JavaScript fails to load, which helps on flaky mobile connections.
- Less client state for server data: you usually don't need Redux, React Query or Zustand to cache server data. Loaders and revalidation cover most of it.
Stick with Next.js if you need static site generation (blogs, marketing sites), or if you need the largest possible ecosystem of examples and community support.
When I'd Use Remix (and When I Wouldn't)
I'd reach for Remix when most of the app is "read some data, show a form, save it, show the result." Admin panels, internal tools, account settings and checkout flows all fit that shape, and the loader/action model removes a lot of the fetch and cache code you'd otherwise write. It's also a good pick if you have to run on Cloudflare Workers or Lambda rather than Vercel.
I wouldn't use it for a mostly static site like a blog or marketing pages, where Next.js static generation or Astro gives you prebuilt HTML on a CDN with no server to run. I'd also think twice for apps where most of the state lives on the client (a canvas editor, a game, a heavy offline-first tool), because loaders and actions don't help much there. And check the version you're starting on: Remix v2's features have since moved into React Router v7, so a new project today will probably start as a React Router app, and older tutorials (including the @remix-run/* imports above) won't match the docs one to one.
A few things that bite people in production:
- No root ErrorBoundary: one thrown error in a loader without a boundary anywhere above it gives users a bare error page. Add one to
root.tsxfirst. - Revalidation cost: every action re-runs every loader on the page by default. On pages with many slow loaders, use
shouldRevalidatedeliberately. - Secrets in loader data: whatever a loader returns gets serialized into the HTML, so anyone can view it in page source. Return only the fields the UI needs, not the whole database row.
Worth Looking at Next
- Remix Stacks: Official templates (Blues, Indie, Grunge) that come with a database, auth and deployment already set up
- Prisma + Remix: The most common ORM pairing for Remix, with typed queries you call straight from loaders and actions
- Tailwind CSS: Works well with Remix; configure it in
remix.config.jswith the PostCSS preset - Defer: Use Remix's
defer()utility to stream slow data in later while fast data renders right away - Prefetching: Add
prefetch="intent"to<Link>components to preload route data when the user hovers, so navigation feels instant - Resource Routes: Export a loader without a default component to build JSON endpoints, webhooks, or file download routes