| name | remix |
| description | Builds full-stack React applications with Remix using loaders, actions, nested routes, and progressive enhancement. Use when creating Remix projects, implementing data loading, form handling, error boundaries, or deploying to various platforms. |
Remix
Full-stack React framework with nested routing, server-side data loading, progressive enhancement, and bring-your-own-server flexibility.
Quick Start
Create new project:
npx create-remix@latest my-app
cd my-app
npm run dev
Manual setup:
mkdir my-remix-app && cd my-remix-app
npm init -y
npm i @remix-run/node @remix-run/react @remix-run/serve isbot react react-dom
npm i -D @remix-run/dev vite typescript @types/react @types/react-dom
Essential file structure:
app/
root.tsx # Root layout (required)
routes/
_index.tsx # Home page (/)
about.tsx # /about
posts.$slug.tsx # /posts/:slug
entry.client.tsx # Client entry
entry.server.tsx # Server entry
vite.config.ts
File-Based Routing
Route Conventions
| File | Route |
|---|---|
_index.tsx |
/ (index route) |
about.tsx |
/about |
posts._index.tsx |
/posts |
posts.$slug.tsx |
/posts/:slug |
posts.$slug_.edit.tsx |
/posts/:slug/edit (escaping nesting) |
$.tsx |
Splat (catch-all) |
Special Characters
| Character | Purpose | Example |
|---|---|---|
. |
Nested URL segment | posts.new.tsx -> /posts/new |
$ |
Dynamic segment | posts.$id.tsx -> /posts/:id |
_ |
Layout nesting escape | posts_.$id.tsx -> no layout |
() |
Optional segment | ($lang).about.tsx |
Nested Routes
app/routes/
dashboard.tsx # Layout for /dashboard/*
dashboard._index.tsx # /dashboard
dashboard.settings.tsx # /dashboard/settings
dashboard.users.tsx # /dashboard/users
// app/routes/dashboard.tsx (Layout)
import { Outlet } from "@remix-run/react";
export default function DashboardLayout() {
return (
<div className="flex">
<Sidebar />
<main className="flex-1">
<Outlet /> {/* Child routes render here */}
</main>
</div>
);
}
Root Route
// app/root.tsx
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
Loaders (Data Fetching)
Basic Loader
// app/routes/posts._index.tsx
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/node";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const posts = await db.posts.findMany();
return json({ posts });
};
export default function Posts() {
const { posts } = useLoaderData<typeof loader>();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
With Parameters
// app/routes/posts.$slug.tsx
import { json } from "@remix-run/node";
import type { LoaderFunctionArgs } from "@remix-run/node";
export const loader = async ({ params }: LoaderFunctionArgs) => {
const post = await db.posts.findUnique({
where: { slug: params.slug },
});
if (!post) {
throw new Response("Not Found", { status: 404 });
}
return json({ post });
};
With Search Params
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const query = url.searchParams.get("q") ?? "";
const page = Number(url.searchParams.get("page")) || 1;
const posts = await db.posts.findMany({
where: { title: { contains: query } },
skip: (page - 1) * 10,
take: 10,
});
return json({ posts, query, page });
};
With Headers
export const loader = async ({ request }: LoaderFunctionArgs) => {
const authHeader = request.headers.get("Authorization");
// Check auth
if (!authHeader) {
throw new Response("Unauthorized", { status: 401 });
}
return json({ data: "secret" });
};
Actions (Mutations)
Basic Form Action
// app/routes/posts.new.tsx
import { json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import type { ActionFunctionArgs } from "@remix-run/node";
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const title = formData.get("title") as string;
const content = formData.get("content") as string;
// Validate
const errors: Record<string, string> = {};
if (!title) errors.title = "Title is required";
if (!content) errors.content = "Content is required";
if (Object.keys(errors).length > 0) {
return json({ errors }, { status: 400 });
}
// Create post
await db.posts.create({ data: { title, content } });
return redirect("/posts");
};
export default function NewPost() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<div>
<label>
Title
<input type="text" name="title" />
</label>
{actionData?.errors?.title && (
<p className="error">{actionData.errors.title}</p>
)}
</div>
<div>
<label>
Content
<textarea name="content" />
</label>
{actionData?.errors?.content && (
<p className="error">{actionData.errors.content}</p>
)}
</div>
<button type="submit">Create Post</button>
</Form>
);
}
Multiple Actions (Intent Pattern)
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const intent = formData.get("intent");
switch (intent) {
case "create":
return createItem(formData);
case "update":
return updateItem(formData);
case "delete":
return deleteItem(formData);
default:
throw new Response("Invalid intent", { status: 400 });
}
};
// In component
<Form method="post">
<input type="hidden" name="id" value={item.id} />
<button type="submit" name="intent" value="update">Update</button>
<button type="submit" name="intent" value="delete">Delete</button>
</Form>
Forms
Form Component
import { Form } from "@remix-run/react";
// POST to current route
<Form method="post">
<input name="email" type="email" />
<button type="submit">Submit</button>
</Form>
// POST to different route
<Form method="post" action="/subscribe">
<input name="email" type="email" />
<button type="submit">Subscribe</button>
</Form>
// GET (search)
<Form method="get" action="/search">
<input name="q" type="search" />
<button type="submit">Search</button>
</Form>
Pending States
import { Form, useNavigation } from "@remix-run/react";
export default function CreatePost() {
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<Form method="post">
<input name="title" disabled={isSubmitting} />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create"}
</button>
</Form>
);
}
useFetcher (Non-Navigation Mutations)
import { useFetcher } from "@remix-run/react";
export function LikeButton({ postId }: { postId: string }) {
const fetcher = useFetcher();
const isLiking = fetcher.state === "submitting";
return (
<fetcher.Form method="post" action="/api/like">
<input type="hidden" name="postId" value={postId} />
<button type="submit" disabled={isLiking}>
{isLiking ? "..." : "Like"}
</button>
</fetcher.Form>
);
}
Optimistic UI
export function TodoItem({ todo }) {
const fetcher = useFetcher();
// Optimistic value
const isCompleted = fetcher.formData
? fetcher.formData.get("completed") === "true"
: todo.completed;
return (
<fetcher.Form method="post">
<input type="hidden" name="id" value={todo.id} />
<input
type="checkbox"
name="completed"
value="true"
checked={isCompleted}
onChange={(e) => fetcher.submit(e.target.form)}
/>
<span className={isCompleted ? "line-through" : ""}>
{todo.title}
</span>
</fetcher.Form>
);
}
Error Handling
Error Boundary
// app/routes/posts.$slug.tsx
import { useRouteError, isRouteErrorResponse } 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>Error</h1>
<p>{error instanceof Error ? error.message : "Unknown error"}</p>
</div>
);
}
Throwing Responses
export const loader = async ({ params }: LoaderFunctionArgs) => {
const post = await db.posts.findUnique({ where: { slug: params.slug } });
if (!post) {
throw new Response("Post not found", { status: 404 });
}
return json({ post });
};
Meta & Links
Meta Tags
import type { MetaFunction } from "@remix-run/node";
// Static meta
export const meta: MetaFunction = () => {
return [
{ title: "My Page" },
{ name: "description", content: "Page description" },
];
};
// Dynamic meta from loader
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [
{ title: data?.post.title ?? "Post" },
{ name: "description", content: data?.post.excerpt },
];
};
Links (CSS, Fonts)
import type { LinksFunction } from "@remix-run/node";
import styles from "~/styles/app.css?url";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: styles },
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
];
Resource Routes (API)
// app/routes/api.posts.tsx
import { json } from "@remix-run/node";
import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";
// GET /api/posts
export const loader = async ({ request }: LoaderFunctionArgs) => {
const posts = await db.posts.findMany();
return json(posts);
};
// POST /api/posts
export const action = async ({ request }: ActionFunctionArgs) => {
const body = await request.json();
const post = await db.posts.create({ data: body });
return json(post, { status: 201 });
};
Streaming (Deferred Data)
import { defer } from "@remix-run/node";
import { Await, useLoaderData } from "@remix-run/react";
import { Suspense } from "react";
export const loader = async () => {
// Fast data - awaited
const criticalData = await getCriticalData();
// Slow data - deferred
const slowDataPromise = getSlowData();
return defer({
criticalData,
slowData: slowDataPromise,
});
};
export default function Page() {
const { criticalData, slowData } = useLoaderData<typeof loader>();
return (
<div>
<h1>{criticalData.title}</h1>
<Suspense fallback={<p>Loading...</p>}>
<Await resolve={slowData}>
{(data) => <SlowComponent data={data} />}
</Await>
</Suspense>
</div>
);
}
Sessions & Cookies
// app/sessions.server.ts
import { createCookieSessionStorage } from "@remix-run/node";
export const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__session",
httpOnly: true,
maxAge: 60 * 60 * 24 * 7, // 1 week
path: "/",
sameSite: "lax",
secrets: [process.env.SESSION_SECRET!],
secure: process.env.NODE_ENV === "production",
},
});
export const { getSession, commitSession, destroySession } = sessionStorage;
// Usage in loader/action
import { getSession, commitSession } from "~/sessions.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const session = await getSession(request.headers.get("Cookie"));
session.set("userId", user.id);
return redirect("/dashboard", {
headers: {
"Set-Cookie": await commitSession(session),
},
});
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
const session = await getSession(request.headers.get("Cookie"));
const userId = session.get("userId");
if (!userId) {
throw redirect("/login");
}
return json({ user: await getUser(userId) });
};
Common Patterns
Protected Routes
// app/utils/auth.server.ts
export async function requireUser(request: Request) {
const session = await getSession(request.headers.get("Cookie"));
const userId = session.get("userId");
if (!userId) {
throw redirect("/login");
}
const user = await db.users.findUnique({ where: { id: userId } });
if (!user) {
throw redirect("/login");
}
return user;
}
// In route
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
return json({ user });
};
Flash Messages
export const action = async ({ request }: ActionFunctionArgs) => {
const session = await getSession(request.headers.get("Cookie"));
session.flash("success", "Post created successfully!");
return redirect("/posts", {
headers: {
"Set-Cookie": await commitSession(session),
},
});
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
const session = await getSession(request.headers.get("Cookie"));
const message = session.get("success");
return json(
{ message },
{
headers: {
"Set-Cookie": await commitSession(session),
},
}
);
};
Best Practices
- Colocate data with UI - Keep loaders/actions in the same file as components
- Use Form over fetch - Progressive enhancement by default
- Throw responses for errors - Triggers error boundaries
- Use resource routes for APIs - Not everything needs UI
- Defer slow data - Stream non-critical content
- Validate on server - Never trust client data
Common Mistakes
| Mistake | Fix |
|---|---|
| Fetching in useEffect | Use loaders instead |
| Using fetch for mutations | Use Form/useFetcher |
| Not handling loading states | Check navigation.state |
| Forgetting error boundaries | Add ErrorBoundary export |
| Client-side state for server data | Use loader + revalidation |
Reference Files
- references/routing.md - Advanced routing patterns
- references/data-flow.md - Loaders, actions, and revalidation
- references/deployment.md - Deployment to various platforms
Templates
- templates/route.tsx - Standard route template
- templates/resource-route.ts - API route template