Introduction
In 2026, Remix stands out as the premier full-stack React framework for performant, scalable web apps. Unlike Next.js, which separates API and frontend, Remix unifies everything with a data-first paradigm: loaders fetch data on the server before rendering, actions handle mutations optimistically, and native forms skip unnecessary abstractions.
This expert tutorial guides you through building a complete Todo app: CRUD with Prisma (SQLite), session-based authentication, nested routes, optimistic UI via useFetcher, and advanced error handling. Ideal for seniors mastering Remix v3+'s React Server Components-inspired features.
Why Remix? Native SSR performance, nested routes for modular architecture, and zero-config deployment on Vercel/Netlify. By the end, you'll have a production-ready project to bookmark as a reference. (148 words)
Prerequisites
- Node.js 20+ and pnpm 9+
- Proficiency in React, TypeScript, and advanced hooks
- Knowledge of relational databases (SQL)
- Tools: VS Code with Remix/Prisma extensions
- Estimated time: 45 min to implement
Initialize the Remix Project
pnpm create remix@latest mon-app-remix --typescript
cd mon-app-remix
pnpm install @prisma/client prisma @remix-run/node
pnpm prisma init --datasource-provider sqlite
del remix.config.js
pnpm run devThis command creates a Remix v3+ project with TypeScript, installs Prisma for SQLite (perfect for dev/prototypes), and removes the outdated JS config. Run pnpm run dev to verify at http://localhost:3000. Pitfall: Don't forget prisma init to generate schema.prisma.
Project Structure and Root Layout
Remix organizes code with file-based routing: app/routes/ maps directly to URLs. The app/root.tsx defines the global layout, including , , , and .
For experts, enable headers for CSP/security and Live Reload. We're setting up a root with Outlet for nested routes and an optional global loader. Analogy: like a tree, root is the trunk, routes are the branches.
Set Up app/root.tsx
import type { LinksFunction } from '@remix-run/node';
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react';
export const links: LinksFunction = () => [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' },
];
export default function App() {
return (
<html lang="fr">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}This minimal yet pro root.tsx includes preconnects for fonts (perf boost), Outlet for nested routes, and all Remix primitives for SSR hydration. Adds LiveReload for HMR. Pitfall: Without pnpm run dev.
Update entry.server.tsx for Sessions
import type { EntryContext } from '@remix-run/node';
import { RemixServer } from '@remix-run/react';
import { renderToString } from 'react-dom/server';
import { createCookieFactory, createSessionStorageFactory } from '@remix-run/node';
const sessionSecret = process.env.SESSION_SECRET || 'dev-secret';
const cookie = createCookieFactory('remix-todo-session', {
secrets: [sessionSecret],
sameSite: 'lax',
httpOnly: true,
maxAge: 60 * 60 * 24 * 7,
});
export const sessionStorage = createSessionStorageFactory({
cookie,
});
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext
) {
const markup = await renderToString(
<RemixServer context={routerContext} url={request.url} />
);
responseHeaders.set('Content-Type', 'text/html');
return new Response('<!DOCTYPE html>' + markup, {
status: responseStatusCode,
headers: responseHeaders,
});
}Prepares sessions for expert auth with secure cookies (httpOnly, sameSite). Uses Remix v3+ factories. Pitfall: Set SESSION_SECRET in .env for prod; without it, sessions fail over HTTPS.
Implement the Index Route with Loader and Action
Loaders fetch data server-side before rendering (no waterfalls). Actions handle POST/PUT/DELETE, revalidating via revalidatePath. Here, basic CRUD for Todos without a DB yet (in-memory for demo).
For experts, use useLoaderData and native Form. Analogy: loader = prefetch, action = optimistic update.
Create app/routes/index.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node';
import type { ShouldRevalidateFunction } from '@remix-run/react';
import { Form, useActionData, useLoaderData, useRevalidator } from '@remix-run/react';
import { json } from '@remix-run/node';
type Todo = { id: string; text: string; done: boolean };
let todos: Todo[] = [];
export const loader = async ({ request }: LoaderFunctionArgs) => {
return json({ todos });
};
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const action = formData.get('action') as string;
const text = formData.get('text') as string;
if (action === 'create' && text) {
todos.push({ id: crypto.randomUUID(), text, done: false });
} else if (action === 'toggle' && formData.has('id')) {
const id = formData.get('id') as string;
const todo = todos.find(t => t.id === id);
if (todo) todo.done = !todo.done;
} else if (action === 'delete' && formData.has('id')) {
const id = formData.get('id') as string;
todos = todos.filter(t => t.id !== id);
}
return json({ todos }, { status: 201 });
};
export const shouldRevalidate: ShouldRevalidateFunction = ({ actionResult }) => {
return !actionResult;
};
export default function Index() {
const { todos } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const revalidator = useRevalidator();
return (
<div style={{ padding: '2rem', fontFamily: 'system-ui' }}>
<h1>Mes Todos (Remix Expert)</h1>
<Form method="post">
<input name="text" placeholder="Nouveau todo" />
<button name="action" value="create">Ajouter</button>
</Form>
<ul>
{todos.map(todo => (
<li key={todo.id} style={{ display: 'flex', gap: '1rem' }}>
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>{todo.text}</span>
<Form method="post">
<input type="hidden" name="id" value={todo.id} />
<button name="action" value="toggle">Toggle</button>
<button name="action" value="delete" style={{ color: 'red' }}>Suppr</button>
</Form>
</li>
))}
</ul>
</div>
);
}Complete route: loader returns todos, action handles CRUD (create/toggle/delete). shouldRevalidate optimizes (no unnecessary re-fetch). UI with native Form for progressive enhancement. Pitfall: Use crypto.randomUUID() (Node 20+), or add a polyfill fallback.
Integrate Prisma for Persistence
Upgrade to a real DB: Prisma generates type-safe TS client. Simple schema for Todo + User. Auto-migrations.
Expert tip: Use Promise.all in loaders for parallel fetches, defer for streaming (non-blocking UI).
Prisma Schema and Migration
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model User {
id String @id @default(uuid())
email String @unique
todos Todo[]
}
model Todo {
id String @id @default(uuid())
text String
done Boolean @default(false)
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
}Relational User-Todo schema (1:N). UUIDs for scalable IDs. Run pnpm prisma db push then pnpm prisma generate. Pitfall: SQLite for dev; migrate to PostgreSQL in prod via url = env(DATABASE_URL).
Update Loader/Action with Prisma
import { PrismaClient } from '@prisma/client';
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node';
import type { ShouldRevalidateFunction } from '@remix-run/react';
import { Form, useActionData, useLoaderData, useRevalidator } from '@remix-run/react';
import { json, redirect } from '@remix-run/node';
const prisma = new PrismaClient();
export const loader = async ({ request }: LoaderFunctionArgs) => {
const todos = await prisma.todo.findMany({ include: { user: true } });
return json({ todos });
};
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const actionType = formData.get('action') as string;
const userId = 'demo-user-id'; // À remplacer par session.userId
if (actionType === 'create') {
const text = formData.get('text') as string;
await prisma.todo.create({ data: { text, userId } });
} else if (actionType === 'toggle') {
const id = formData.get('id') as string;
const todo = await prisma.todo.findUnique({ where: { id } });
if (todo) await prisma.todo.update({ where: { id }, data: { done: !todo.done } });
} else if (actionType === 'delete') {
const id = formData.get('id') as string;
await prisma.todo.delete({ where: { id } });
}
return json({ success: true });
};
export const shouldRevalidate: ShouldRevalidateFunction = ({ actionResult }) => {
return !(actionResult as any)?.success;
};
export default function Index() {
const { todos } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
return (
<div style={{ padding: '2rem', fontFamily: 'system-ui' }}>
<h1>Todos Prisma</h1>
<Form method="post">
<input name="text" placeholder="Nouveau todo" />
<button name="action" value="create">Ajouter</button>
</Form>
<ul>
{todos.map((todo: any) => (
<li key={todo.id} style={{ display: 'flex', gap: '1rem' }}>
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>{todo.text} ({todo.user.email})</span>
<Form method="post">
<input type="hidden" name="id" value={todo.id} />
<button name="action" value="toggle">Toggle</button>
<button name="action" value="delete">Suppr</button>
</Form>
</li>
))}
</ul>
</div>
);
}Integrates Prisma: loader fetches with include (relations), action performs type-safe mutations. userId hardcoded (link to auth later). Optimized with shouldRevalidate. Pitfall: No need for prisma.$disconnect() in dev, but wrap in prod to avoid leaks.
Authentication with Sessions and useFetcher
Remix sessions: Store userId in signed cookies. Protectors: Check session in loader.
Expert: useFetcher for optimistic mutations (pending states, no navigation).
Auth Middleware and lib/auth.ts
import { createCookieSessionStorage } from '@remix-run/node';
import type { Session } from '@remix-run/node';
const sessionSecret = process.env.SESSION_SECRET || 's3cret';
const storage = createCookieSessionStorage({
cookie: {
name: '__session',
httpOnly: true,
path: '/',
sameSite: 'lax',
secrets: [sessionSecret],
secure: process.env.NODE_ENV === 'production',
},
});
export async function getSession(request: Request) {
const cookie = request.headers.get('Cookie');
return await storage.getSession(cookie);
}
export async function requireUserId(
request: Request,
redirectTo: string = new URL(request.url).pathname
) {
const session = await getSession(request);
const userId = session.get('userId');
if (!userId || typeof userId !== 'string') {
const searchParams = new URLSearchParams([['redirectTo', redirectTo]]);
throw redirect(`/login?${searchParams.toString()}`);
}
return userId;
}
export function commitSession(session: Session) {
return storage.commitSession(session);
}Utility lib: getSession, requireUserId (protects loader/action), commitSession. Production-ready secure. Pitfall: Set secure: true in prod for HTTPS-only.
Login Route and Optimistic useFetcher
import type { ActionFunctionArgs } from '@remix-run/node';
import { Form, useFetcher } from '@remix-run/react';
import { json, redirect } from '@remix-run/node';
import { getSession } from '~/lib/auth';
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const email = formData.get('email') as string;
const session = await getSession(request);
session.set('userId', 'user-' + email.split('@')[0]);
return redirect('/', {
headers: {
'Set-Cookie': await commitSession(session),
},
});
};
export default function Login() {
const fetcher = useFetcher();
return (
<div style={{ padding: '2rem' }}>
<fetcher.Form method="post">
<input name="email" type="email" placeholder="email@example.com" />
<button type="submit" disabled={fetcher.state !== 'idle'}>Login</button>
</fetcher.Form>
</div>
);
}Simple demo login route, sets session.userId. useFetcher for submit without full reload, pending state. Redirect with Set-Cookie. Pitfall: Import commitSession from lib.
Nested Routes and Error Boundaries
Nested routes: routes/resources._index.tsx for child indexes. ErrorBoundary: Catches granular errors.
Add app/routes/resources._index.tsx for filtered todos.
Nested Route and Error Boundary
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export const loader = async ({ params }: LoaderFunctionArgs) => {
if (!params['*']) throw new Error('Route invalide');
const todos = await prisma.todo.findMany({ where: { done: false } });
return json({ todos });
};
export function ErrorBoundary({ error }: { error: Error }) {
return (
<html>
<head>
<title>Erreur!</title>
</head>
<body>
<h1>Erreur: {error.message}</h1>
<p>Route nested échouée.</p>
</body>
</html>
);
}
export default function ResourcesIndex() {
const { todos } = useLoaderData<typeof loader>();
return (
<div>
<h2>Todos non faits</h2>
<ul>{todos.map((todo: any) => <li key={todo.id}>{todo.text}</li>)}</ul>
</div>
);
}Catch-all nested route (resources._index), filters DB. Local ErrorBoundary (granular). Pitfall: Use params['*'] for splat routes; test at /resources.
Best Practices
- Strict data flow: Always use loader/action, avoid useEffect fetches (Remix anti-pattern).
- Optimistic UI: Combine useFetcher + useOptimistic for pending states.
- Type safety: Generate Prisma types (
prisma generate), usezodfor formData validation. - Perf:
defer({ slow: promise })in loader for streaming HTML + data. - Security: Validate inputs with schemas, rate-limit actions, CSP headers in root.
Common Errors to Avoid
- Forgetting revalidation: Without
useRevalidator().revalidate()or shouldRevalidate, UI stays stale post-mutation. - useEffect for data: Breaks SSR, causes hydration mismatch; use loaders instead.
- Uncommitted sessions: Missing Set-Cookie headers → immediate logout.
- Prisma on client: Never! Keep DB queries server-only to avoid bundle leaks.
Next Steps
- Official docs: remix.run/docs
- Advanced video: Remix Run YouTube
- Expert training: Learni Group Courses on Remix + tRPC.
- Deploy to Vercel:
vercel --prodafterprisma db push.