Skip to content
Learni
Voir tous les tutoriels
Développement Web

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

Read in English

Introduction

En 2026, Remix s'impose comme le framework React full-stack par excellence pour les applications web performantes et scalables. Contrairement à Next.js qui sépare API et frontend, Remix unifie le tout via un paradigme data-first : les loaders fetchent les données au serveur avant le rendu, les actions gèrent les mutations de manière optimiste, et les formulaires natifs évitent les abstractions inutiles.

Ce tutoriel expert vous guide pour bâtir une app Todo complète : CRUD avec Prisma (SQLite), authentification par sessions, routes imbriquées, UI optimiste via useFetcher, et gestion d'erreurs avancées. Idéal pour les seniors voulant maîtriser le React Server Components-inspiré de Remix v3+.

Pourquoi Remix ? Performances SSR natives, nested routes pour une architecture modulaire, et déploiement zéro-config sur Vercel/Netlify. À la fin, vous aurez un projet production-ready, bookmarkable pour référence. (148 mots)

Prérequis

  • Node.js 20+ et pnpm 9+
  • Maîtrise de React, TypeScript et hooks avancés
  • Connaissances en bases de données relationnelles (SQL)
  • Outils : VS Code avec extensions Remix/Prisma
  • Temps estimé : 45 min pour implémenter

Initialiser le projet Remix

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

Cette commande crée un projet Remix v3+ avec TypeScript, installe Prisma pour SQLite (idéal pour dev/protos), supprime le config JS obsolète. Lancez pnpm run dev pour vérifier sur http://localhost:3000. Piège : n'oubliez pas prisma init pour générer schema.prisma.

Structure du projet et root layout

Remix organise le code en routes file-based : app/routes/ mappe directement les URLs. Le app/root.tsx définit le layout global, incluant , , et .

Pour expert, activez les headers pour CSP/security et Live Reload. Nous préparons un root avec Outlet pour nested routes et un loader global optionnel. Analogie : comme un arbre, root est la racine, routes les branches.

Configurer app/root.tsx

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>
  );
}

Ce root.tsx minimal mais pro inclut préconnexions pour fonts (perf), Outlet pour nested routes, et tous les Remix primitives pour hydration SSR. Ajoute LiveReload pour HMR. Piège : sans , les styles ne chargent pas ; testez avec pnpm run dev.

Mettre à jour entry.server.tsx pour sessions

app/entry.server.tsx
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,
  });
}

Prépare les sessions pour auth experte avec cookies sécurisés (httpOnly, sameSite). Utilise Remix v3+ factories. Piège : définissez SESSION_SECRET en .env pour prod ; sans ça, sessions échouent en HTTPS.

Implémenter la route index avec loader et action

Loaders : fetchent données serveur-side avant rendu (pas de waterfalls). Actions : gèrent POST/PUT/DELETE, revalident via revalidatePath. Ici, CRUD basique pour Todos sans DB encore (en memoire pour demo).

Pour expert, utilisez useLoaderData et Form natif. Analogie : loader = prefetch, action = optimistic update.

Créer app/routes/index.tsx

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>
  );
}

Route complète : loader renvoie todos, action gère CRUD (create/toggle/delete). shouldRevalidate optimise (pas de re-fetch inutile). UI avec Form natif pour progressive enhancement. Piège : utilisez crypto.randomUUID() (Node 20+), sinon fallback polyfill.

Intégrer Prisma pour persistence

Passez à une vraie DB : Prisma génère client TS type-safe. Schema simple pour Todo + User. Migrations auto.

Expert tip : Promise.all dans loader pour parallel fetch, defer pour streaming (non-bloquant UI).

Schema Prisma et migration

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

Schema relationnel User-Todo (1:N). UUID pour IDs scalables. Lancez pnpm prisma db push puis pnpa prisma generate. Piège : SQLite en dev, migrez PostgreSQL prod via url = env(DATABASE_URL).

Mettre à jour loader/action avec Prisma

app/routes/index.tsx
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>
  );
}

Intègre Prisma : loader fetches avec include (relations), action mutations type-safe. userId hardcodé (à lier à auth). Optimisé avec shouldRevalidate. Piège : prisma.$disconnect() en dev non needed, mais wrappez en prod pour leaks.

Authentification avec sessions et useFetcher

Sessions Remix : stockez userId en cookie signé. Protecteurs : check session in loader.

Expert : useFetcher pour mutations optimistes (pending states, no navigation).

Middleware auth et lib/auth.ts

app/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);
}

Lib utilitaire : getSession, requireUserId (protect loader/action), commitSession. Sécurisé prod-ready. Piège : secure: true en prod pour HTTPS only.

Route login et useFetcher optimiste

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

Route login simple (demo), set session.userId. useFetcher pour submit sans full reload, pending state. Redirect avec Set-Cookie. Piège : importez commitSession depuis lib.

Routes imbriquées et error boundaries

Nested routes : routes/resources._index.tsx pour index enfants. ErrorBoundary : catch erreurs granularisées.

Ajoutez app/routes/resources._index.tsx pour todos filtrés.

Route nested et error boundary

app/routes/resources._index.tsx
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>
  );
}

Route catch-all nested (resources._index), filtre DB. ErrorBoundary local (granulaire). Piège : params['*'] pour splat routes ; testez /resources.

Bonnes pratiques

  • Data flow strict : toujours loader/action, évitez useEffect fetch (anti-pattern Remix).
  • Optimistic UI : combinez useFetcher + useOptimistic pour pending states.
  • Type safety : générez types Prisma (prisma generate), utilisez zod pour validation formData.
  • Perf : defer({ slow: promise }) dans loader pour stream HTML + data.
  • Sécurité : validez inputs avec schemas, rate-limit actions, CSP headers dans root.

Erreurs courantes à éviter

  • Oublier revalidation : sans useRevalidator().revalidate() ou shouldRevalidate, UI stale après mutation.
  • useEffect pour data : casse SSR, cause hydration mismatch ; utilisez loaders.
  • Sessions non commit : headers Set-Cookie manquants → logout immédiat.
  • Prisma en client : jamais ! DB queries server-only, sinon bundle leak.

Pour aller plus loin