Skip to content
Learni
View all tutorials
Remix

Comment créer une app full-stack avancée avec Remix en 2026

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

terminal
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 dev

Cette 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

prisma/schema.prisma
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

app/lib/auth.server.ts
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é

app/routes/_index.tsx
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

app/routes/new.tsx
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

app/root.tsx
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

app/routes/login.tsx
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

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 shouldRevalidate pour cache finement (ex: skip sur actions).
  • Intégrez deferred pour 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 useFetcher pour 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.