React Router v7 : Construire une application full-stack avec le Framework Mode

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

React Router v7 marque un tournant majeur dans le développement React. En fusionnant avec Remix, il devient un véritable framework full-stack capable de gérer le rendu côté serveur (SSR), le chargement de données et les mutations — tout cela avec une API unifiée et élégante.

Dans ce tutoriel, vous allez construire une application de gestion de contacts complète avec React Router v7 en Framework Mode : chargement de données via des loaders, mutations via des actions, validation de formulaires, gestion des erreurs et rendu côté serveur.

Prérequis

Avant de commencer, assurez-vous de disposer de :

  • Node.js 20+ installé sur votre machine
  • npm ou pnpm comme gestionnaire de paquets
  • Des connaissances de base en React et TypeScript
  • Une familiarité avec les concepts HTTP (GET, POST, PUT, DELETE)

Ce que vous allez construire

Une application de gestion de contacts avec les fonctionnalités suivantes :

  • Liste des contacts avec recherche en temps réel
  • Création, modification et suppression de contacts
  • Validation des formulaires côté serveur
  • Gestion des erreurs avec des Error Boundaries
  • Rendu côté serveur (SSR) pour un meilleur SEO

Étape 1 : Créer le projet

React Router v7 propose un CLI pour initialiser un nouveau projet en mode framework :

npx create-react-router@latest contacts-app
cd contacts-app

Le CLI vous posera quelques questions. Sélectionnez les options suivantes :

  • Template : Basic
  • TypeScript : Yes
  • Package manager : npm (ou pnpm selon votre préférence)

Structure du projet

Voici la structure générée :

contacts-app/
├── app/
│   ├── routes/
│   │   └── home.tsx
│   ├── root.tsx
│   ├── routes.ts
│   └── app.css
├── react-router.config.ts
├── vite.config.ts
├── tsconfig.json
└── package.json

Les fichiers clés sont :

FichierRôle
react-router.config.tsConfiguration globale du framework
app/routes.tsDéfinition des routes de votre application
app/root.tsxLayout racine (HTML shell)
app/routes/*.tsxModules de route (composants, loaders, actions)

Étape 2 : Configurer le SSR

Ouvrez react-router.config.ts et activez le rendu côté serveur :

import type { Config } from "@react-router/dev/config";
 
export default {
  ssr: true,
} satisfies Config;

Avec ssr: true, React Router va :

  • Exécuter les loaders côté serveur avant de rendre la page
  • Envoyer le HTML complet au navigateur (meilleur SEO, meilleur temps de chargement)
  • Hydrater le côté client pour l'interactivité

Étape 3 : Créer le modèle de données

Créez un fichier app/data/contacts.ts pour simuler une base de données :

export interface Contact {
  id: string;
  firstName: string;
  lastName: string;
  email: string;
  phone?: string;
  notes?: string;
  createdAt: string;
}
 
const contacts: Map<string, Contact> = new Map();
 
// Données initiales
const initialContacts: Contact[] = [
  {
    id: "1",
    firstName: "Amira",
    lastName: "Ben Ali",
    email: "amira@example.com",
    phone: "+216 50 123 456",
    notes: "Développeuse frontend",
    createdAt: "2026-01-15",
  },
  {
    id: "2",
    firstName: "Youssef",
    lastName: "Mansour",
    email: "youssef@example.com",
    phone: "+216 55 789 012",
    notes: "Designer UX/UI",
    createdAt: "2026-02-01",
  },
  {
    id: "3",
    firstName: "Leila",
    lastName: "Trabelsi",
    email: "leila@example.com",
    notes: "Chef de projet",
    createdAt: "2026-02-20",
  },
];
 
initialContacts.forEach((c) => contacts.set(c.id, c));
 
let nextId = 4;
 
export function getContacts(query?: string): Contact[] {
  let result = Array.from(contacts.values());
  if (query) {
    const q = query.toLowerCase();
    result = result.filter(
      (c) =>
        c.firstName.toLowerCase().includes(q) ||
        c.lastName.toLowerCase().includes(q) ||
        c.email.toLowerCase().includes(q)
    );
  }
  return result.sort(
    (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
  );
}
 
export function getContact(id: string): Contact | undefined {
  return contacts.get(id);
}
 
export function createContact(
  data: Omit<Contact, "id" | "createdAt">
): Contact {
  const id = String(nextId++);
  const contact: Contact = {
    ...data,
    id,
    createdAt: new Date().toISOString().split("T")[0],
  };
  contacts.set(id, contact);
  return contact;
}
 
export function updateContact(
  id: string,
  data: Partial<Omit<Contact, "id" | "createdAt">>
): Contact | null {
  const existing = contacts.get(id);
  if (!existing) return null;
  const updated = { ...existing, ...data };
  contacts.set(id, updated);
  return updated;
}
 
export function deleteContact(id: string): boolean {
  return contacts.delete(id);
}

Dans une application réelle, vous utiliseriez une base de données comme PostgreSQL avec Prisma ou Drizzle ORM. Ce module en mémoire suffit pour apprendre les concepts de React Router v7.

Étape 4 : Définir les routes

Ouvrez app/routes.ts et définissez la structure de vos routes :

import { type RouteConfig, route, index, layout } from "@react-router/dev/routes";
 
export default [
  layout("routes/layout.tsx", [
    index("routes/home.tsx"),
    route("contacts/new", "routes/contacts-new.tsx"),
    route("contacts/:contactId", "routes/contact-detail.tsx"),
    route("contacts/:contactId/edit", "routes/contact-edit.tsx"),
    route("contacts/:contactId/delete", "routes/contact-delete.tsx"),
  ]),
] satisfies RouteConfig;

Chaque route est un module qui peut exporter un loader, une action, un composant par défaut et un ErrorBoundary.

Étape 5 : Créer le layout principal

Créez app/routes/layout.tsx — le layout partagé entre toutes les pages :

import { Outlet, Link, useNavigation } from "react-router";
 
export default function AppLayout() {
  const navigation = useNavigation();
  const isNavigating = navigation.state !== "idle";
 
  return (
    <div className="app-layout">
      <header className="app-header">
        <Link to="/" className="logo">
          <h1>Contacts</h1>
        </Link>
        <nav>
          <Link to="/contacts/new" className="btn btn-primary">
            + Nouveau contact
          </Link>
        </nav>
      </header>
 
      <main className={isNavigating ? "loading" : ""}>
        <Outlet />
      </main>
 
      <footer className="app-footer">
        <p>Construit avec React Router v7</p>
      </footer>
    </div>
  );
}

Le composant <Outlet /> est essentiel : il rend le contenu de la route enfant active. Le hook useNavigation() permet de détecter les transitions et afficher un état de chargement.

Étape 6 : Page d'accueil avec loader et recherche

Remplacez le contenu de app/routes/home.tsx :

import { useLoaderData, Form, useSearchParams } from "react-router";
import { getContacts } from "~/data/contacts";
import type { Route } from "./+types/home";
 
export async function loader({ request }: Route.LoaderArgs) {
  const url = new URL(request.url);
  const query = url.searchParams.get("q") || undefined;
  const contacts = getContacts(query);
  return { contacts, query };
}
 
export default function Home() {
  const { contacts, query } = useLoaderData<typeof loader>();
  const [searchParams] = useSearchParams();
 
  return (
    <div className="home-page">
      <div className="search-section">
        <Form method="get" className="search-form">
          <input
            type="search"
            name="q"
            placeholder="Rechercher un contact..."
            defaultValue={query || ""}
            aria-label="Rechercher des contacts"
          />
          <button type="submit">Rechercher</button>
        </Form>
      </div>
 
      <div className="contacts-list">
        <h2>
          {query
            ? `Résultats pour "${query}" (${contacts.length})`
            : `Tous les contacts (${contacts.length})`}
        </h2>
 
        {contacts.length === 0 ? (
          <p className="empty-state">
            {query
              ? "Aucun contact trouvé pour cette recherche."
              : "Aucun contact. Créez-en un !"}
          </p>
        ) : (
          <ul className="contact-cards">
            {contacts.map((contact) => (
              <li key={contact.id}>
                <a href={`/contacts/${contact.id}`} className="contact-card">
                  <div className="contact-avatar">
                    {contact.firstName[0]}
                    {contact.lastName[0]}
                  </div>
                  <div className="contact-info">
                    <strong>
                      {contact.firstName} {contact.lastName}
                    </strong>
                    <span>{contact.email}</span>
                  </div>
                </a>
              </li>
            ))}
          </ul>
        )}
      </div>
    </div>
  );
}

Comment fonctionne le loader ?

  1. Le loader est exécuté côté serveur avant le rendu du composant
  2. Il reçoit l'objet request avec tous les paramètres de la requête HTTP
  3. Les données retournées sont automatiquement sérialisées et accessibles via useLoaderData()
  4. Le composant <Form method="get"> soumet un formulaire GET qui met à jour les paramètres de recherche sans rechargement complet

Étape 7 : Formulaire de création avec action

Créez app/routes/contacts-new.tsx :

import { Form, redirect, useActionData, useNavigation } from "react-router";
import { createContact } from "~/data/contacts";
import type { Route } from "./+types/contacts-new";
 
interface ActionErrors {
  firstName?: string;
  lastName?: string;
  email?: string;
}
 
export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const firstName = String(formData.get("firstName") || "").trim();
  const lastName = String(formData.get("lastName") || "").trim();
  const email = String(formData.get("email") || "").trim();
  const phone = String(formData.get("phone") || "").trim();
  const notes = String(formData.get("notes") || "").trim();
 
  // Validation côté serveur
  const errors: ActionErrors = {};
 
  if (!firstName) {
    errors.firstName = "Le prénom est requis";
  }
  if (!lastName) {
    errors.lastName = "Le nom est requis";
  }
  if (!email) {
    errors.email = "L'email est requis";
  } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    errors.email = "Format d'email invalide";
  }
 
  if (Object.keys(errors).length > 0) {
    return { errors };
  }
 
  const contact = createContact({
    firstName,
    lastName,
    email,
    phone: phone || undefined,
    notes: notes || undefined,
  });
 
  return redirect(`/contacts/${contact.id}`);
}
 
export default function NewContact() {
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";
  const errors = actionData?.errors;
 
  return (
    <div className="form-page">
      <h2>Nouveau contact</h2>
 
      <Form method="post" className="contact-form">
        <div className="form-group">
          <label htmlFor="firstName">Prénom *</label>
          <input
            id="firstName"
            name="firstName"
            type="text"
            required
            aria-invalid={errors?.firstName ? true : undefined}
            aria-describedby={
              errors?.firstName ? "firstName-error" : undefined
            }
          />
          {errors?.firstName && (
            <p id="firstName-error" className="error-message">
              {errors.firstName}
            </p>
          )}
        </div>
 
        <div className="form-group">
          <label htmlFor="lastName">Nom *</label>
          <input
            id="lastName"
            name="lastName"
            type="text"
            required
            aria-invalid={errors?.lastName ? true : undefined}
          />
          {errors?.lastName && (
            <p className="error-message">{errors.lastName}</p>
          )}
        </div>
 
        <div className="form-group">
          <label htmlFor="email">Email *</label>
          <input
            id="email"
            name="email"
            type="email"
            required
            aria-invalid={errors?.email ? true : undefined}
          />
          {errors?.email && (
            <p className="error-message">{errors.email}</p>
          )}
        </div>
 
        <div className="form-group">
          <label htmlFor="phone">Téléphone</label>
          <input id="phone" name="phone" type="tel" />
        </div>
 
        <div className="form-group">
          <label htmlFor="notes">Notes</label>
          <textarea id="notes" name="notes" rows={3} />
        </div>
 
        <div className="form-actions">
          <button type="submit" className="btn btn-primary" disabled={isSubmitting}>
            {isSubmitting ? "Création en cours..." : "Créer le contact"}
          </button>
          <a href="/" className="btn btn-secondary">
            Annuler
          </a>
        </div>
      </Form>
    </div>
  );
}

Les points clés de cette action

  • <Form method="post"> envoie les données via une requête POST interceptée par React Router
  • action() est exécutée côté serveur, elle reçoit les données du formulaire
  • La validation se fait côté serveur — les erreurs sont retournées et accessibles via useActionData()
  • En cas de succès, redirect() redirige vers la page du nouveau contact
  • useNavigation() permet de désactiver le bouton pendant la soumission

Étape 8 : Page de détail du contact

Créez app/routes/contact-detail.tsx :

import { useLoaderData, Link } from "react-router";
import { getContact } from "~/data/contacts";
import type { Route } from "./+types/contact-detail";
 
export async function loader({ params }: Route.LoaderArgs) {
  const contact = getContact(params.contactId);
  if (!contact) {
    throw new Response("Contact introuvable", { status: 404 });
  }
  return { contact };
}
 
export default function ContactDetail() {
  const { contact } = useLoaderData<typeof loader>();
 
  return (
    <div className="detail-page">
      <div className="contact-header">
        <div className="contact-avatar large">
          {contact.firstName[0]}
          {contact.lastName[0]}
        </div>
        <div>
          <h2>
            {contact.firstName} {contact.lastName}
          </h2>
          <p className="contact-email">{contact.email}</p>
        </div>
      </div>
 
      <div className="contact-details">
        {contact.phone && (
          <div className="detail-row">
            <span className="label">Téléphone</span>
            <span>{contact.phone}</span>
          </div>
        )}
 
        {contact.notes && (
          <div className="detail-row">
            <span className="label">Notes</span>
            <p>{contact.notes}</p>
          </div>
        )}
 
        <div className="detail-row">
          <span className="label">Ajouté le</span>
          <span>
            {new Date(contact.createdAt).toLocaleDateString("fr-FR", {
              year: "numeric",
              month: "long",
              day: "numeric",
            })}
          </span>
        </div>
      </div>
 
      <div className="detail-actions">
        <Link to={`/contacts/${contact.id}/edit`} className="btn btn-primary">
          Modifier
        </Link>
        <Link to={`/contacts/${contact.id}/delete`} className="btn btn-danger">
          Supprimer
        </Link>
        <Link to="/" className="btn btn-secondary">
          Retour
        </Link>
      </div>
    </div>
  );
}
 
export function ErrorBoundary() {
  return (
    <div className="error-page">
      <h2>Contact introuvable</h2>
      <p>Le contact que vous recherchez n'existe pas ou a été supprimé.</p>
      <Link to="/" className="btn btn-primary">
        Retour à la liste
      </Link>
    </div>
  );
}

Lancer une Response avec un status 404 dans un loader déclenche automatiquement le ErrorBoundary le plus proche. C'est le pattern standard pour gérer les ressources introuvables dans React Router v7.

Étape 9 : Modification avec action et loader combinés

Créez app/routes/contact-edit.tsx :

import { Form, redirect, useLoaderData, useActionData, useNavigation } from "react-router";
import { getContact, updateContact } from "~/data/contacts";
import type { Route } from "./+types/contact-edit";
 
export async function loader({ params }: Route.LoaderArgs) {
  const contact = getContact(params.contactId);
  if (!contact) {
    throw new Response("Contact introuvable", { status: 404 });
  }
  return { contact };
}
 
export async function action({ request, params }: Route.ActionArgs) {
  const formData = await request.formData();
  const firstName = String(formData.get("firstName") || "").trim();
  const lastName = String(formData.get("lastName") || "").trim();
  const email = String(formData.get("email") || "").trim();
  const phone = String(formData.get("phone") || "").trim();
  const notes = String(formData.get("notes") || "").trim();
 
  const errors: Record<string, string> = {};
  if (!firstName) errors.firstName = "Le prénom est requis";
  if (!lastName) errors.lastName = "Le nom est requis";
  if (!email) errors.email = "L'email est requis";
 
  if (Object.keys(errors).length > 0) {
    return { errors };
  }
 
  const updated = updateContact(params.contactId, {
    firstName,
    lastName,
    email,
    phone: phone || undefined,
    notes: notes || undefined,
  });
 
  if (!updated) {
    throw new Response("Contact introuvable", { status: 404 });
  }
 
  return redirect(`/contacts/${params.contactId}`);
}
 
export default function EditContact() {
  const { contact } = useLoaderData<typeof loader>();
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";
  const errors = actionData?.errors;
 
  return (
    <div className="form-page">
      <h2>Modifier {contact.firstName} {contact.lastName}</h2>
 
      <Form method="post" className="contact-form">
        <div className="form-group">
          <label htmlFor="firstName">Prénom *</label>
          <input
            id="firstName"
            name="firstName"
            type="text"
            defaultValue={contact.firstName}
            required
            aria-invalid={errors?.firstName ? true : undefined}
          />
          {errors?.firstName && (
            <p className="error-message">{errors.firstName}</p>
          )}
        </div>
 
        <div className="form-group">
          <label htmlFor="lastName">Nom *</label>
          <input
            id="lastName"
            name="lastName"
            type="text"
            defaultValue={contact.lastName}
            required
          />
          {errors?.lastName && (
            <p className="error-message">{errors.lastName}</p>
          )}
        </div>
 
        <div className="form-group">
          <label htmlFor="email">Email *</label>
          <input
            id="email"
            name="email"
            type="email"
            defaultValue={contact.email}
            required
          />
          {errors?.email && (
            <p className="error-message">{errors.email}</p>
          )}
        </div>
 
        <div className="form-group">
          <label htmlFor="phone">Téléphone</label>
          <input
            id="phone"
            name="phone"
            type="tel"
            defaultValue={contact.phone || ""}
          />
        </div>
 
        <div className="form-group">
          <label htmlFor="notes">Notes</label>
          <textarea
            id="notes"
            name="notes"
            rows={3}
            defaultValue={contact.notes || ""}
          />
        </div>
 
        <div className="form-actions">
          <button type="submit" className="btn btn-primary" disabled={isSubmitting}>
            {isSubmitting ? "Enregistrement..." : "Enregistrer"}
          </button>
          <a href={`/contacts/${contact.id}`} className="btn btn-secondary">
            Annuler
          </a>
        </div>
      </Form>
    </div>
  );
}

Ce module de route illustre parfaitement la puissance de React Router v7 : un seul fichier contient le loader (chargement des données), l'action (mutation) et le composant (interface). Tout est colocalisé et type-safe.

Étape 10 : Suppression avec confirmation

Créez app/routes/contact-delete.tsx :

import { Form, redirect, useLoaderData } from "react-router";
import { getContact, deleteContact } from "~/data/contacts";
import type { Route } from "./+types/contact-delete";
 
export async function loader({ params }: Route.LoaderArgs) {
  const contact = getContact(params.contactId);
  if (!contact) {
    throw new Response("Contact introuvable", { status: 404 });
  }
  return { contact };
}
 
export async function action({ params }: Route.ActionArgs) {
  deleteContact(params.contactId);
  return redirect("/");
}
 
export default function DeleteContact() {
  const { contact } = useLoaderData<typeof loader>();
 
  return (
    <div className="confirm-page">
      <h2>Confirmer la suppression</h2>
      <p>
        Êtes-vous sûr de vouloir supprimer le contact{" "}
        <strong>
          {contact.firstName} {contact.lastName}
        </strong>{" "}
        ? Cette action est irréversible.
      </p>
 
      <div className="confirm-actions">
        <Form method="post">
          <button type="submit" className="btn btn-danger">
            Oui, supprimer
          </button>
        </Form>
        <a href={`/contacts/${contact.id}`} className="btn btn-secondary">
          Annuler
        </a>
      </div>
    </div>
  );
}

Étape 11 : Gestion globale des erreurs

Modifiez app/root.tsx pour ajouter une gestion globale des erreurs :

import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  isRouteErrorResponse,
} from "react-router";
import type { Route } from "./+types/root";
import "./app.css";
 
export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="fr">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}
 
export default function App() {
  return <Outlet />;
}
 
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
  if (isRouteErrorResponse(error)) {
    return (
      <div className="error-container">
        <h1>
          {error.status} {error.statusText}
        </h1>
        <p>{error.data}</p>
        <a href="/">Retour à l'accueil</a>
      </div>
    );
  }
 
  return (
    <div className="error-container">
      <h1>Erreur inattendue</h1>
      <p>Une erreur inattendue est survenue. Veuillez réessayer.</p>
      <a href="/">Retour à l'accueil</a>
    </div>
  );
}

Étape 12 : Lancer et tester

Démarrez le serveur de développement :

npm run dev

Votre application est accessible sur http://localhost:5173. Testez les fonctionnalités :

  1. Accueil — La liste des contacts est chargée via le loader SSR
  2. Recherche — Tapez un nom dans la barre de recherche, les résultats se mettent à jour
  3. Création — Cliquez sur « Nouveau contact », remplissez le formulaire
  4. Validation — Soumettez un formulaire incomplet pour voir les erreurs serveur
  5. Modification — Ouvrez un contact et modifiez ses informations
  6. Suppression — Supprimez un contact avec la page de confirmation

Concepts clés à retenir

Loader vs Action

ConceptMéthode HTTPRôleExécution
loaderGETCharger des donnéesAvant le rendu
actionPOST, PUT, DELETEMuter des donnéesLors de la soumission

Revalidation automatique

Après chaque action, React Router revalide automatiquement tous les loaders actifs. Cela signifie que lorsque vous créez un contact, la liste sur la page d'accueil est automatiquement mise à jour — sans aucun code supplémentaire.

Type Safety

React Router v7 génère automatiquement des types pour chaque module de route dans le dossier .react-router/types/. Cela vous donne un typage complet pour :

  • Les paramètres de route (params.contactId)
  • Les arguments de loader et action
  • Les données retournées par useLoaderData()

Dépannage

Les types ne sont pas générés

Lancez la commande suivante pour régénérer les types :

npx react-router typegen

Erreur "Cannot find module ./+types/"

Assurez-vous que tsconfig.json inclut les chemins auto-générés :

{
  "compilerOptions": {
    "rootDirs": [".", "./.react-router/types"]
  }
}

Le SSR ne fonctionne pas

Vérifiez que ssr: true est défini dans react-router.config.ts et que vous utilisez loader (et non clientLoader).

Prochaines étapes

Maintenant que vous maîtrisez les bases de React Router v7 en mode framework, voici des pistes pour aller plus loin :

  • Ajouter une base de données : Remplacez le stockage en mémoire par Prisma + PostgreSQL ou Drizzle ORM + SQLite
  • Authentification : Implémentez un système de sessions avec des cookies
  • Optimistic UI : Utilisez useFetcher() pour des mises à jour optimistes sans navigation
  • Déploiement : Déployez sur Vercel, Cloudflare Workers ou un VPS avec Docker
  • React Server Components : Explorez le support expérimental des RSC dans React Router v7

Conclusion

React Router v7 en mode framework représente une approche moderne et unifiée pour construire des applications React full-stack. En combinant loaders, actions et SSR dans des modules de route colocalisés, il simplifie considérablement le développement tout en offrant d'excellentes performances et un typage complet.

Les concepts appris dans ce tutoriel — chargement de données côté serveur, validation de formulaires, mutations et gestion des erreurs — constituent les fondations pour construire des applications web robustes et performantes avec React.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur 5 Les Bases de Laravel 11 : Controllers.

Discutez de votre projet avec nous

Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.

Trouvons les meilleures solutions pour vos besoins.

Articles connexes

Construire une application full-stack en temps réel avec Convex et Next.js 15

Apprenez à construire une application full-stack en temps réel avec Convex et Next.js 15. Ce tutoriel couvre la conception de schémas, les requêtes, les mutations, les abonnements en temps réel, l'authentification et le téléchargement de fichiers — le tout avec une sécurité de types de bout en bout.

30 min read·