Skip to content
Learni
Voir tous les tutoriels
Frameworks Web

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

Read in English

Introduction

Remix, framework full-stack basé sur React, révolutionne le développement web en 2026 en priorisant les données et les formulaires natifs. Contrairement à Next.js qui mise sur le SSR statique, Remix excelle dans les apps interactives avec son paradigme loaders (pour charger les données au serveur) et actions (pour muter les données via formulaires HTML standards).

Ce tutoriel avancé vous guide pour créer une app de gestion de tâches : authentification, CRUD avec Prisma (SQLite pour simplicité), gestion d'erreurs, UI optimiste et déploiement sur Vercel. Idéal pour les seniors voulant scaler des apps performantes sans boilerplate excessif.

Pourquoi Remix ? Il réduit les waterfalls de données, gère les reconnexions réseau automatiquement et optimise les bundles. À la fin, vous aurez un projet production-ready, bookmarkable pour référence. (128 mots)

Prérequis

  • Node.js 20+ installé
  • Connaissances avancées en React et TypeScript
  • Compte GitHub et Vercel pour déploiement
  • Outils : Prisma CLI, Remix CLI (npm create remix@latest)
  • Éditeur : VS Code avec extensions Remix et Prisma

Installation du projet Remix

terminal
npx create-remix@latest mon-app-remix --template remix-run/remix/templates/remix
cd mon-app-remix
npm install @prisma/client prisma
npm install @remix-run/auth lucide-react
npm install -D @types/node
npx prisma init --datasource-provider sqlite

Cette commande crée un nouveau projet Remix avec template officiel, ajoute Prisma pour la DB SQLite, Remix Auth pour l'authentification et Lucide pour les icônes. Le flag --datasource-provider sqlite configure Prisma pour un fichier local DB, parfait pour dev sans setup externe. Évitez les templates JS purs ; TypeScript est obligatoire pour advanced.

Structure du projet

Remix organise les routes via le filesystem : app/routes/ définit les URLs. app/root.tsx est l'entrée, avec pour nested routes. Les loaders/actions vivent dans les fichiers route. Prisma génère prisma/schema.prisma et prisma/migrations/.

Analogie : comme un arbre, routes/_index.tsx est /, routes/taches.$id.tsx gère /taches/123. Cela rend le code scalable sans config centralisée.

Configuration Prisma et modèles

prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model User {
  id    String   @id @default(cuid())
  email String   @unique
  name  String?
  tasks Task[]
}

model Task {
  id          String   @id @default(cuid())
  title       String
  completed   Boolean  @default(false)
  userId      String
  user        User     @relation(fields: [userId], references: [id])
  createdAt   DateTime @default(now())
}

model Session {
  id     String @id @default(cuid())
  userId String
  user   User   @relation(fields: [userId], references: [id])
}

Définit User, Task et Session pour auth/CRUD. CUID pour IDs distribués, relations pour intégrité. SQLite via file:./dev.db pour portabilité. Exécutez npx prisma generate && npx prisma db push après pour générer client et migrer.

Route index avec loader authentifié

app/routes/_index.tsx
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { Link, Outlet, useLoaderData } from '@remix-run/react';
import { getSession } from '~/sessions';

export async function loader({ request }: LoaderFunctionArgs) {
  const session = await getSession(request.headers.get('Cookie'));
  const userId = session.get('userId');
  if (!userId) throw new Response('Unauthorized', { status: 401 });
  return json({ userId });
}

export default function Index() {
  const { userId } = useLoaderData<typeof loader>();
  return (
    <div>
      <h1>Bienvenue !</h1>
      <p>ID User: {userId}</p>
      <Link to="/taches">Mes tâches</Link>
      <Outlet />
    </div>
  );
}

Loader exécuté côté serveur pour fetch données/auth. useLoaderData typé pour revalidation auto. Vérif session évite CSRF. Piège : oublier request dans args mène à erreurs runtime ; toujours typer LoaderFunctionArgs.

Mécanisme des loaders et actions

Loaders : chargent données avant render, comme un getServerSideProps mais par route, avec cache et parallélisme. Actions : gèrent mutations via

, revalident loaders post-submit.

Avantage : pas de useEffect pour data fetching, réseau résilient. Nested routes héritent des parents.

Route tâches avec CRUD et Prisma

app/routes/taches.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import { Form, useActionData, useLoaderData, useNavigation } from '@remix-run/react';
import { PrismaClient } from '@prisma/client';
import { getSession } from '~/sessions';

const prisma = new PrismaClient();

export async function loader({ request }: LoaderFunctionArgs) {
  const session = await getSession(request.headers.get('Cookie'));
  const userId = session.get('userId');
  const tasks = await prisma.task.findMany({ where: { userId } });
  return json({ tasks });
}

export async function action({ request }: ActionFunctionArgs) {
  const session = await getSession(request.headers.get('Cookie'));
  const userId = session.get('userId');
  const formData = await request.formData();
  const intent = formData.get('intent');
  if (intent === 'create') {
    const title = formData.get('title') as string;
    await prisma.task.create({ data: { title, userId } });
  } else if (intent === 'toggle') {
    const id = formData.get('id') as string;
    const task = await prisma.task.findUnique({ where: { id } });
    if (task?.userId === userId) {
      await prisma.task.update({ where: { id }, data: { completed: !task.completed } });
    }
  }
  return redirect('/taches');
}

export default function Taches() {
  const { tasks } = useLoaderData<typeof loader>();
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  const isSubmitting = navigation.state === 'submitting';
  return (
    <div>
      <h2>Mes tâches</h2>
      <Form method="post">
        <input name="title" placeholder="Nouvelle tâche" />
        <button name="intent" value="create" disabled={isSubmitting}>Ajouter</button>
      </Form>
      <ul>
        {tasks.map((task: any) => (
          <li key={task.id}>
            <Form method="post">
              <input type="hidden" name="id" value={task.id} />
              <input type="hidden" name="intent" value="toggle" />
              <label>
                <input type="checkbox" checked={task.completed} readOnly />
                {task.title}
              </label>
              <button type="submit" disabled={isSubmitting}>Toggle</button>
            </Form>
          </li>
        ))}
      </ul>
    </div>
  );
}

Loader query DB par user, action gère create/toggle avec validation session. useNavigation pour UI optimiste (désactive boutons). PrismaClient singleton pour perf. Piège : ne pas utiliser redirect après mutation bloque revalidation ; toujours formData.get('intent') pour multi-actions.

Gestion d'erreurs et boundaries

Remix a ErrorBoundary et CatchBoundary par route. Globale dans root.tsx. Pour advanced : utilisez handleError dans loaders/actions pour logs custom.

ErrorBoundary et session helper

app/sessions.ts
import { createCookieSessionStorage } from '@remix-run/node';

const sessionSecret = process.env.SESSION_SECRET || 'dev-secret';
export const sessionStorage = 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 sessionStorage.getSession(cookie);
}

export async function createUserSession({
  request,
  userId,
  remember,
}: {
  request: Request;
  userId: string;
  remember?: boolean;
}) {
  const session = await getSession(request);
  session.set('userId', userId);
  return sessionStorage.commitSession(session, {
    maxAge: remember ? 60 * 60 * 24 * 7 : undefined,
  });
}

Session storage sécurisé avec Remix. createUserSession pour login. Secrets env pour prod. Piège : sans httpOnly et secure, vulnérable XSS/CSRF. Utilisez dans routes login/register.

Route login avec action

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 { createUserSession, getSession } from '~/sessions';
import { prisma } from '~/db';

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; // Hash en prod!
  const user = await prisma.user.findUnique({ where: { email } });
  if (!user || user.password !== password) { // Simplifié
    return json({ error: 'Invalid credentials' }, { status: 400 });
  }
  return redirect('/', {
    headers: {
      'Set-Cookie': await createUserSession({ request, userId: user.id }),
    },
  });
}

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 valide login, set cookie via headers. Erreur retournée à client. En prod : bcrypt pour hash. Piège : oublier headers dans redirect perd la session.

Déploiement et optimisations

remix.config.js pour server build. Déployez sur Vercel : vercel --prod. Ajoutez prisma generate dans build script.

Scripts package.json et root.tsx avec Outlet

package.json (scripts) + app/root.tsx
{
  "scripts": {
    "dev": "remix dev",
    "build": "remix build && prisma generate",
    "start": "remix-serve build/index.js",
    "db:push": "prisma db push",
    "db:seed": "prisma db seed"
  }
}

// app/root.tsx
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration, useRouteError } from '@remix-run/react';
import { getSession } from '~/sessions';
import type { LinksFunction } from '@remix-run/node';

export const links: LinksFunction = () => [
  { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css?family=Roboto' },
];

export function ErrorBoundary() {
  const error = useRouteError();
  console.error(error);
  return <h1>Erreur: {error instanceof Error ? error.message : 'Inconnu'}</h1>;
}

export default function App() {
  return (
    <html>
      <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>
  );
}

Scripts pour build/deploy avec Prisma. root.tsx inclut Outlet pour nested, ErrorBoundary globale, links pour assets. Piège : sans , hydration échoue.

Bonnes pratiques

  • Typage strict : Utilisez LoaderFunctionArgs partout pour autocomplétion.
  • Sessions sécurisées : Toujours httpOnly, secure en prod, rotate secrets.
  • Revalidation fine : revalidatePath ou useRevalidator pour updates partiels.
  • Perf DB : Prisma select explicite (select: { id: true, title: true }), raw queries pour complexité.
  • Tests : @remix-run/testing pour loaders/actions isolés.

Erreurs courantes à éviter

  • Oublier await dans actions : mutations async sans wait causent race conditions.
  • Sessions sans check userId : exposition données cross-user.
  • Pas de useNavigation : UX bloquée sans feedback submit.
  • DB en client : Prisma seul serveur-side ; leaks mémoire si global mal géré.

Pour aller plus loin

  • Docs officielles : Remix.run
  • Avancé : Streaming SSR, Deployments multi-env.
  • Formations Learni pour Remix + React expert.
  • GitHub exemple : fork ce projet, seed DB avec npx prisma db seed.