Remix: Building Full-Stack React Apps
Remix is a full-stack web framework built on top of React that takes a fundamentally different approach to building modern web applications. Created by Ryan Florence and Michael Jackson—the same team behind React Router—Remix embraces web standards rather than abstracting over them. The result is a framework that delivers exceptional performance through parallel data loading, resilient user experiences that work even without JavaScript, and a mental model so simple it makes other frameworks feel overcomplicated.
While frameworks like Next.js have dominated the React full-stack space, Remix challenges that status quo with a philosophy-first approach: lean on the platform, use HTTP properly, and keep the server and client in sync without magic. If you've ever been frustrated by client-side waterfalls, complex state synchronization, or bloated JavaScript bundles, Remix was designed specifically to address those pain points.
Remix was acquired by Shopify in 2022 and powers the Shopify Hydrogen storefront framework. It runs on any JavaScript runtime—Node.js, Deno, Cloudflare Workers, AWS Lambda—making it one of the most deployment-flexible React frameworks available today.
What is Remix?
At its core, Remix is a compiler and server-side runtime that wraps React Router. Every Remix app is essentially a deeply integrated React Router app with a server layer built in. This is not coincidental—the same mental model you use for client-side routing maps directly to how Remix handles server requests.
Remix introduces three core primitives that form the foundation of every application:
- Loaders: Server-side functions that fetch data for a route before rendering. They run in parallel, eliminating the waterfall problem common in client-rendered SPAs.
- Actions: Server-side functions that handle form submissions and mutations. They replace complex client-side state management for most use cases.
- Route Components: React components that consume data from loaders and trigger actions through HTML forms—the way the web was designed to work.
Remix routes data from server loaders directly into React components — no client-side fetching required
Installation and Setup
Getting a Remix project up and running takes under two minutes. Remix provides an official create-remix CLI that scaffolds a complete starter project with your chosen server adapter and styling approach.
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—giving you full control over the 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
Remix uses a flat file naming convention to define routes. Unlike Next.js, which uses nested directories to create nested routes, Remix encodes the nesting directly in the filename using dots as separators. This approach makes the entire routing structure visible at a glance without navigating folder trees.
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. Perfect for grouping authenticated routes or marketing pages under a shared header/footer.
Remix routes hierarchy — flat files with dots encode nested layouts
Loading Data with Loaders
The loader function is one of Remix's most powerful features. It runs on the server before the route component renders and can fetch from databases, external APIs, or any async data source. Because each route in a layout stack exports its own loader, all data is fetched in parallel—eliminating the cascading waterfall that plagues client-rendered React apps.
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
Because every route in the layout tree exports its own loader, Remix fetches all of them simultaneously. Consider a dashboard with a sidebar, main content area, and a footer widget—all three load their data at the same time, not 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 full TypeScript type inference from server to component—no need for separate API type definitions. Remix infers the return type of your json() call automatically.
Form Actions and Mutations
Remix handles mutations through action functions—server-side handlers for form submissions. This is a deliberate return to how the web originally worked: HTML forms POST data to a URL, the server processes it and redirects. No client-side fetch, no optimistic update state machines, no cache invalidation headaches.
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 — perfect for inline edits, likes, or background syncs:
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 Remix's secret weapon for building complex UIs without complexity. 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—like Russian nesting dolls, but for URLs.
The real power shows when the user navigates 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 data is never refetched—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
Remix provides a first-class error boundary system that co-locates error UI with 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—including parent layouts—continues to render 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, giving you full SEO control without a third-party head management 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 Remix and Next.js are excellent full-stack React frameworks, but they make different trade-offs. Understanding the differences helps you pick the right tool for your project.
| 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) | Excellent 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 shines in these scenarios:
- Apps with heavy user interaction and form-driven workflows — CRUD apps, dashboards, admin panels, SaaS products benefit enormously from actions and loaders.
- When deploying to edge runtimes — Cloudflare Workers, Deno Deploy, and AWS Lambda are first-class citizens. Remix's server agnosticism is unmatched.
- When you want progressive enhancement by default — Remix forms work even if JavaScript fails to load, giving you resilient experiences in unreliable network conditions.
- When you're tired of client-side state management for server data — No Redux, no React Query, no Zustand for server state. Loaders and revalidation handle it all.
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.
Conclusion and Next Steps
Remix represents a genuine philosophical shift in how full-stack React apps are built. By leaning on web standards—HTTP, HTML forms, the browser's native fetch and navigation APIs—it achieves better performance, simpler code, and more resilient user experiences than most client-heavy frameworks. The loader/action/component triad is the entire data layer of a Remix app; once that clicks, everything else follows naturally.
Keep Learning
- Remix Stacks: Official templates (Blues, Indie, Grunge) for fast project bootstrapping with database, auth, and deployment pre-configured
- Prisma + Remix: The most common ORM pairing for Remix — type-safe database queries that plug directly into loaders and actions
- Tailwind CSS: Pairs beautifully with Remix's component model — configure in
remix.config.jswith the PostCSS preset - Defer: Use Remix's
defer()utility to stream slow data progressively while fast data renders immediately - Prefetching: Add
prefetch="intent"to<Link>components to preload route data when the user hovers — instant navigation feel - Resource Routes: Export a loader without a default component to build JSON endpoints, webhooks, or file download routes
"Remix is not a compromise between server and client. It's a synthesis — embrace both, fight neither."
— Ryan Florence, Co-creator of Remix
Whether you're building a SaaS dashboard, an e-commerce storefront, or a content platform, Remix gives you the tools to build fast, resilient, and maintainable full-stack applications. Its commitment to web standards means the skills you develop are transferable—you're not learning an abstraction, you're getting closer to the platform itself.