Introduction
En 2026, Remix domine les frameworks full-stack React grâce à son respect des standards web : pas de JavaScript superflu côté client, des formulaires natifs boostés par des actions et loaders pour des données fraîches à chaque navigation. Contrairement à Next.js App Router, Remix excelle dans les transitions fluides et l'optimistic UI sans état global lourd.
Ce tutoriel avancé vous guide pour créer une app todo-list sécurisée : authentification, CRUD avec Prisma (PostgreSQL), routes imbriquées, gestion d'erreurs et déploiement Vercel. Vous apprendrez à exploiter les deferred pour du streaming, les error boundaries natives et l'auth avec Lucia. À la fin, vous aurez un projet production-ready, scalable et SEO-friendly. Parfait pour les seniors voulant booster leurs perf web vitals. (128 mots)
Prérequis
- Node.js 20+ et PNPM 9+
- Connaissances avancées en React, TypeScript et SQL
- Compte PostgreSQL (ex: Neon ou Supabase)
- Vercel pour déploiement
- Git installé
Installation du projet Remix
pnpm create remix@latest mon-app-remix --typescript --deployment vercel
cd mon-app-remix
pnpm install @prisma/client prisma lucia-auth @lucia-auth/adapter-prisma bcryptjs @remix-run/node
pnpm prisma init --datasource-provider postgresql
del .env.example
pnpm devCette commande crée un nouveau projet Remix TypeScript optimisé Vercel, installe Prisma pour l'ORM, Lucia pour l'auth session-based et bcryptjs pour le hashage. Le prisma init configure le schéma DB avec PostgreSQL. Lancez pnpm dev pour hot-reload sur localhost:3000. Piège : Vérifiez DATABASE_URL dans .env.
Configuration de la base de données
Remix brille avec des données serveur-first. Nous utilisons Prisma pour sa génération de types TS infaillible et migrations zero-downtime. Ajoutez votre DATABASE_URL dans .env, puis définissez le schéma pour users et todos.
Schéma Prisma complet
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
password String
name String?
todos Todo[]
sessions Session[]
accounts Account[]
@@map("users")
}
model Todo {
id String @id @default(cuid())
title String
completed Boolean @default(false)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@map("todos")
}
model Account {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
email String
password String?
@@unique([email])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
idleExpiresAt DateTime?
@@unique([id])
@@map("sessions")
}Ce schéma définit User avec auth, Todo lié par foreign key et Session pour Lucia. Utilisez CUID pour IDs courts/distribués. @@map lowercase les tables Postgres. Exécutez pnpm prisma db push puis pnpm prisma generate pour types TS. Piège : Toujours onDelete: Cascade pour cleanup.
Middleware d'authentification Lucia
import { Auth } from "lucia-auth";
import { adapter } from "./prisma-auth";
import { prisma } from "./db.server";
import { cookies } from "@remix-run/node";
const auth = new Auth(
adapter(prisma),
{
sessionCookie: {
attributes: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
},
},
}
);
export type User = typeof auth.user;
export const getUser = async (cookiesHeader: string) => {
const sessionId = auth.parseCookie(cookiesHeader);
if (!sessionId) return null;
const session = await auth.getSession(sessionId);
return session.user;
};
export const logout = async (request: Request) => {
const sessionCookie = cookies.headers(request);
const sessionId = auth.parseCookie(sessionCookie);
if (!sessionId) return;
auth.invalidateSession(sessionId);
};
export default auth;Ce module centralise Lucia avec adapter Prisma pour sessions DB. getUser extrait l'utilisateur via cookie Remix. Sécurisé avec HttpOnly/secure en prod. Piège : Ne pas exposer sessions en mémoire ; DB persiste tout.
Routes de base avec loaders et actions
Les loaders fetchent les données serveur-side à chaque navigation (pas de cache client forcé). Les actions gèrent les mutations POST/PUT/DELETE avec revalidation auto. Analogy : Loader = useEffect serveur, Action = onSubmit boosté.
Route index avec loader protégé
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Link, Outlet, useLoaderData } from "@remix-run/react";
import { getUser } from "~/lib/auth.server";
import { prisma } from "~/lib/db.server";
export async function loader({ request }: LoaderFunctionArgs) {
const user = await getUser(request.headers.get("Cookie") || "");
if (!user) throw redirect("/login");
const todos = await prisma.todo.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
});
return json({ todos, user });
}
export default function Index() {
const { todos, user } = useLoaderData<typeof loader>();
return (
<div>
<h1>Bonjours, {user.name || user.email}!</h1>
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title} - {todo.completed ? "✓" : "○"}</li>
))}
</ul>
<Link to="new">Nouveau Todo</Link>
<Outlet />
</div>
);
}Loader protège la route (redirect si non auth), fetch todos filtrés par user. Types inférés via typeof loader. Nested Outlet pour enfants. Piège : Toujours typer LoaderFunctionArgs pour headers/params.
Route new todo avec action et optimistic UI
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { prisma } from "~/lib/db.server";
import { getUser } from "~/lib/auth.server";
export async function action({ request }: ActionFunctionArgs) {
const user = await getUser(request.headers.get("Cookie") || "");
if (!user) throw redirect("/login");
const formData = await request.formData();
const title = formData.get("title") as string;
if (!title || title.length < 3) {
return json({ error: "Titre trop court" }, { status: 400 });
}
await prisma.todo.create({
data: { title, userId: user.id },
});
return redirect("/");
}
export default function NewTodo() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
return (
<Form method="post">
<input
name="title"
placeholder="Nouveau todo"
required
className={navigation.state === "submitting" ? "submitting" : ""}
/>
{actionData?.error && <p>{actionData.error}</p>}
<button type="submit" disabled={navigation.state === "submitting"}>
Créer
</button>
</Form>
);
}Action valide et crée todo, redirect pour revalidation. useActionData pour erreurs, useNavigation pour loading states (optimistic). Form natif POST. Piège : Validez toujours côté serveur ; client est malléable.
Gestion des erreurs et deferred
Error boundaries capturent les throws dans loaders/actions. deferred stream les données lourdes sans bloquer le HTML initial, idéal Core Web Vitals.
Root layout avec error boundary et deferred
import type { LinksFunction } from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
} from "@remix-run/react";
import { useRouteError } from "@remix-run/react";
import { Link } from "@remix-run/react";
import { json } from "@remix-run/node";
import { getUser } from "~/lib/auth.server";
import type { LoaderFunctionArgs } from "@remix-run/node";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await getUser(request.headers.get("Cookie") || "");
return json({ user });
};
export function ErrorBoundary() {
const error = useRouteError();
console.error(error);
return (
<html>
<head>
<title>Erreur!</title>
<Meta />
<Links />
</head>
<body>
<h1>Erreur 500</h1>
<p>Quelque chose a mal tourné.</p>
<Link to="/">Accueil</Link>
<Scripts />
</body>
</html>
);
}
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>
);
}ErrorBoundary wrappe Outlet pour catch erreurs routes. Loader root fetch user global. Scripts/LiveReload pour HMR. Piège : Loggez toujours erreurs en prod avec Sentry.
Route login avec action auth
import type { ActionFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { prisma } from "~/lib/db.server";
import auth from "~/lib/auth.server";
import bcrypt from "bcryptjs";
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 user = await prisma.user.findUnique({ where: { email } });
if (!user || !await bcrypt.compare(password, user.password)) {
return json({ error: "Invalid credentials" }, { status: 400 });
}
const session = await auth.createSession(user.id);
const cookie = await auth.createSessionCookie(session.id);
return redirect("/", {
headers: { "Set-Cookie": cookie },
});
}
export default function Login() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<input name="email" type="email" required />
<input name="password" type="password" required />
{actionData?.error && <p>{actionData.error}</p>}
<button type="submit">Login</button>
</Form>
);
}Action hash-check password avec bcrypt, crée session Lucia, set cookie Remix. Redirect sécurisé. Ajoutez register similaire. Piège : Hash toujours passwords ; jamais plain-text en DB.
Déploiement Vercel
Push Git, connectez Vercel : auto-detect Remix, Prisma preview DBs gérées. Ajoutez pnpm prisma generate et prisma db push en build hooks.
Script build et vercel.json
{
"build": {
"command": "pnpm build",
"env": {
"PRISMA_GENERATE_SKIP_AUTOINSTALL": "true"
}
},
"installCommand": "pnpm install",
"functions": {
"app/api/**/*": {
"maxDuration": 30
}
}
}Config Vercel pour PNPM, skip Prisma auto-install (vuln), timeout API 30s. Build auto génère Prisma client. Piège : DATABASE_URL preview/prod séparées.
Bonnes pratiques
- Toujours serveur-first : Loaders pour data-fetch, zéro useEffect client.
- Utilisez
shouldRevalidatepour cache finement (ex: skip sur actions). - Intégrez
deferredpour analytics/lazy data :return deferred({ data: slowQuery() }). - Auth middleware global via root loader.
- Tests E2E avec Playwright sur actions/loaders.
Erreurs courantes à éviter
- Oublier
headers.get('Cookie')dans loaders : auth perdue. - Mutations sans
redirect: stale data client. - Ignorer
useFetcherpour side-effects (ex: likes) : pas de reval. - DB queries non-indexées : scale fail sur prod.
Pour aller plus loin
Plongez dans docs Remix, Lucia Auth et Prisma Accelerate pour edge caching. Découvrez nos formations Learni full-stack pour maîtriser Remix en équipe. Contribuez sur GitHub pour nested errors avancés.