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

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-appLe 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 :
| Fichier | Rôle |
|---|---|
react-router.config.ts | Configuration globale du framework |
app/routes.ts | Définition des routes de votre application |
app/root.tsx | Layout racine (HTML shell) |
app/routes/*.tsx | Modules 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 ?
- Le
loaderest exécuté côté serveur avant le rendu du composant - Il reçoit l'objet
requestavec tous les paramètres de la requête HTTP - Les données retournées sont automatiquement sérialisées et accessibles via
useLoaderData() - 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 Routeraction()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 devVotre application est accessible sur http://localhost:5173. Testez les fonctionnalités :
- Accueil — La liste des contacts est chargée via le loader SSR
- Recherche — Tapez un nom dans la barre de recherche, les résultats se mettent à jour
- Création — Cliquez sur « Nouveau contact », remplissez le formulaire
- Validation — Soumettez un formulaire incomplet pour voir les erreurs serveur
- Modification — Ouvrez un contact et modifiez ses informations
- Suppression — Supprimez un contact avec la page de confirmation
Concepts clés à retenir
Loader vs Action
| Concept | Méthode HTTP | Rôle | Exécution |
|---|---|---|---|
loader | GET | Charger des données | Avant le rendu |
action | POST, PUT, DELETE | Muter des données | Lors 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 typegenErreur "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.
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.

Construire une application temps réel avec Supabase et Next.js 15 : guide complet
Apprenez à construire une application full-stack en temps réel avec Supabase et Next.js 15 App Router. Ce guide couvre l'authentification, la base de données, Row Level Security et les abonnements temps réel.

Construire une application full-stack avec TanStack Start : le framework React nouvelle génération
Apprenez à créer une application full-stack complète avec TanStack Start, le meta-framework React propulsé par TanStack Router et Vite. Ce tutoriel couvre le routage fichier, les fonctions serveur, le middleware, l'authentification et le déploiement.