Overview
Remix is a full-stack web framework built on React that embraces web standards and modern web development practices. It uses nested routing, server-side rendering, and progressive enhancement to deliver fast, resilient user experiences. Remix handles data loading and mutations through loader and action functions that run on the server, keeping sensitive logic and data out of the client bundle while providing seamless data flow between server and UI.
Originally created by Ryan Florence and Michael Jackson (creators of React Router), Remix was open-sourced in 2022 and later acquired by Shopify. The framework leverages the Web Fetch API, FormData, and HTTP caching to build applications that work even before JavaScript loads in the browser. Remix v2 merged with React Router v7, unifying routing and data patterns under a single API.
Installation
Create New Project
# Create new Remix project
npx create-remix@latest my-app
# With specific template
npx create-remix@latest --template remix-run/indie-stack my-app
npx create-remix@latest --template remix-run/blues-stack my-app
# Navigate and start
cd my-app
npm install
npm run dev
Project Templates
| Template | Description |
|---|
indie-stack | SQLite, Prisma, Tailwind, auth, testing |
blues-stack | PostgreSQL, Prisma, Docker, CI/CD |
grunge-stack | AWS Lambda, DynamoDB, serverless |
| Basic | Minimal Remix app |
Project Structure
| Path | Description |
|---|
app/root.tsx | Root layout component |
app/routes/ | File-based routes |
app/components/ | Shared React components |
app/models/ | Data models / DB queries |
app/utils/ | Utility functions |
app/entry.server.tsx | Server entry point |
app/entry.client.tsx | Client entry point |
public/ | Static assets |
remix.config.js | Remix configuration |
Routing
File-Based Routes
| File | URL | Description |
|---|
app/routes/_index.tsx | / | Home page |
app/routes/about.tsx | /about | Static page |
app/routes/blog.tsx | /blog | Blog layout |
app/routes/blog._index.tsx | /blog | Blog index |
app/routes/blog.$slug.tsx | /blog/:slug | Dynamic param |
app/routes/blog.$.tsx | /blog/* | Splat/catch-all |
app/routes/_auth.login.tsx | /login | Pathless layout |
app/routes/files.$.tsx | /files/* | Catch-all route |
Route Module
import type { LoaderFunctionArgs, ActionFunctionArgs, MetaFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData, useActionData, Form } from "@remix-run/react";
// Meta tags
export const meta: MetaFunction = () => {
return [
{ title: "My Page" },
{ name: "description", content: "Page description" },
];
};
// Server-side data loading (GET requests)
export async function loader({ request, params }: LoaderFunctionArgs) {
const url = new URL(request.url);
const query = url.searchParams.get("q");
const posts = await db.post.findMany({ where: { title: { contains: query } } });
return json({ posts, query });
}
// Server-side form handling (POST/PUT/DELETE)
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const title = formData.get("title");
const body = formData.get("body");
const errors: Record<string, string> = {};
if (!title) errors.title = "Title is required";
if (!body) errors.body = "Body is required";
if (Object.keys(errors).length > 0) {
return json({ errors }, { status: 400 });
}
const post = await db.post.create({ data: { title, body } });
return redirect(`/blog/${post.slug}`);
}
// Component
export default function BlogPage() {
const { posts, query } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
return (
<div>
<h1>Blog</h1>
<Form method="post">
<input name="title" />
{actionData?.errors?.title && <p>{actionData.errors.title}</p>}
<textarea name="body" />
<button type="submit">Create Post</button>
</Form>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
Data Loading Patterns
Loader Patterns
// Basic loader
export async function loader() {
const users = await getUsers();
return json({ users });
}
// With authentication
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireUser(request);
if (!user) throw redirect("/login");
const data = await getUserData(user.id);
return json({ user, data });
}
// With headers (caching)
export async function loader() {
const data = await fetchData();
return json(data, {
headers: {
"Cache-Control": "public, max-age=300",
},
});
}
// Throwing responses
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.slug);
if (!post) {
throw new Response("Not Found", { status: 404 });
}
return json({ post });
}
Using Data in Components
import { useLoaderData, useFetcher, useNavigation } from "@remix-run/react";
export default function Page() {
const data = useLoaderData<typeof loader>();
const navigation = useNavigation();
const fetcher = useFetcher();
const isSubmitting = navigation.state === "submitting";
const isLoading = navigation.state === "loading";
return (
<div>
{/* Fetcher for non-navigation mutations */}
<fetcher.Form method="post" action="/api/subscribe">
<input name="email" />
<button disabled={fetcher.state !== "idle"}>
{fetcher.state === "submitting" ? "Subscribing..." : "Subscribe"}
</button>
</fetcher.Form>
</div>
);
}
import { Form, useNavigation, useActionData } from "@remix-run/react";
export default function ContactForm() {
const navigation = useNavigation();
const actionData = useActionData<typeof action>();
const isSubmitting = navigation.state === "submitting";
return (
<Form method="post">
<label>
Name: <input name="name" required />
</label>
<label>
Email: <input name="email" type="email" required />
</label>
{/* Intent-based actions */}
<button type="submit" name="intent" value="save">Save Draft</button>
<button type="submit" name="intent" value="publish">Publish</button>
</Form>
);
}
// Action handler
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
switch (intent) {
case "save":
await saveDraft(Object.fromEntries(formData));
return json({ success: true });
case "publish":
await publish(Object.fromEntries(formData));
return redirect("/posts");
default:
throw new Response("Invalid intent", { status: 400 });
}
}
Configuration
remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
ignoredRouteFiles: ["**/.*"],
serverModuleFormat: "esm",
tailwind: true,
postcss: true,
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
},
};
Environment Variables
// Only in loaders/actions (server-side)
const API_KEY = process.env.API_KEY;
// For client-side, expose via loader
export async function loader() {
return json({
ENV: {
PUBLIC_API_URL: process.env.PUBLIC_API_URL,
},
});
}
Advanced Usage
Error Boundaries
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
);
}
return (
<div>
<h1>Unexpected Error</h1>
<p>{error instanceof Error ? error.message : "Unknown error"}</p>
</div>
);
}
Resource Routes (API Endpoints)
// app/routes/api.users.tsx (no default export = resource route)
export async function loader({ request }: LoaderFunctionArgs) {
const users = await getUsers();
return json(users);
}
export async function action({ request }: ActionFunctionArgs) {
if (request.method === "DELETE") {
const formData = await request.formData();
await deleteUser(formData.get("id") as string);
return json({ success: true });
}
return json({ error: "Method not allowed" }, { status: 405 });
}
Streaming with defer
import { defer } from "@remix-run/node";
import { Await, useLoaderData } from "@remix-run/react";
import { Suspense } from "react";
export async function loader() {
const criticalData = await getCriticalData(); // Awaited
const slowData = getSlowData(); // Promise (not awaited)
return defer({
critical: criticalData,
slow: slowData,
});
}
export default function Page() {
const { critical, slow } = useLoaderData<typeof loader>();
return (
<div>
<h1>{critical.title}</h1>
<Suspense fallback={<p>Loading...</p>}>
<Await resolve={slow}>
{(data) => <SlowComponent data={data} />}
</Await>
</Suspense>
</div>
);
}
Troubleshooting
| Problem | Solution |
|---|
| Hydration mismatch | Ensure server/client render same content; check useEffect for client-only code |
| Loader not re-running | Check route nesting; use shouldRevalidate for control |
| Form not submitting | Ensure method="post" on Form; check action function exists |
| 404 on routes | Verify file naming convention; check routes/ directory |
| Environment vars undefined | Server-only vars in loader; use window.ENV for client |
| Stale data after mutation | Remix auto-revalidates; check if shouldRevalidate blocks it |
| CSS not loading | Check links export in route; verify build pipeline |
| Deploy errors | Check server adapter matches target (Node, Vercel, Cloudflare) |