Cet article est publié en anglais.
Migrer les API Express vers des gestionnaires de routes du Next.js App Router
Apprenez à convertir les routes Express, le middleware et les schémas de données en Next.js App Router avec des composants serveur, ainsi qu’aux considérations liées au déploiement.
Quand migrer vers Next.js a vraiment du sens (et quand ce n’est pas le cas)
Le Next.js App Router devient un choix judicieux lorsque votre projet remplit au moins deux de ces conditions :
- La vitesse de chargement initial ou la visibilité sur les moteurs de recherche sont cruciales et vous utilisez actuellement une application monopage qui récupère des données depuis un backend Express après le chargement de la page.
- Votre pipeline de déploiement vise déjà Vercel ou des plateformes similaires ou vous êtes prêt à adopter cette infrastructure — les fonctionnalités edge et serverless de l’App Router reposent sur cet environnement d’hébergement.
- Votre application Express sert principalement des pages et gère des opérations CRUD standard plutôt que de gérer des connexions persistantes, des tâches en arrière-plan ou des processus gourmands en ressources.
La migration n’est pas recommandée dans les cas suivants :
- Votre backend gère des tâches importantes en dehors du rendu des pages — files d’attente de messages, tâches planifiées, points de terminaison gRPC ou connexions socket persistantes. Les gestionnaires de route de Next.js ne peuvent pas remplacer un service backend dédié ; vous devrez probablement déployer Next.js en tant que couche frontend tout en maintenant un service Express ou Node distinct en arrière-plan.
- Vous dépendez fortement d’un écosystème de middleware Express bien établi — des fournisseurs d’authentification spécialisés, des bibliothèques de limitation de fréquence ou des intégrations d’observabilité — qui nécessiterait une réimplémentation complète pour un gain minime.
- Votre application se compose principalement d’interfaces de tableau de bord interactives et authentifiées, avec des exigences limitées en matière d’outils de recherche — les avantages principaux du App Router (rendu serveur en streaming, optimisation pour les moteurs de recherche, génération statique) offrent peu de valeur dans ce contexte, tandis que l’investissement en formation reste important.
Une approche recommandée : conserver votre API Express en tant que source autorisée pour les données et les règles métier, puis utiliser Next.js comme couche de rendu et d’backend pour le frontend. Migratez d’abord l’interface utilisateur ainsi que les routes orientées lecture, en laissant inchangés les services internes à forte charge d’écriture. Ce guide suit cette stratégie de migration.
Mappage des routes Express vers les gestionnaires de route de Next.js
Les gestionnaires de route situés dans app/api/**/route.ts constituent le remplacement le plus direct des définitions de routes Express. Le changement conceptuel réside dans le fait que, au lieu des objets req et res, vous recevez un objet Request et vous renvoyez un objet Response (ou utilisez NextResponse pour plus de commodité) ; de plus, chaque méthode HTTP devient une fonction exportée distincte plutôt qu’un appel à la méthode router.get().
Considérez une route Express standard qui récupère et crée des commandes :
// express: routes/orders.ts
import ⟨0⟩ from "express";
import ⟨1⟩ from "../db";
import ⟨2⟩ from "../middleware/auth";
const router = Router(); router.get("/api/orders", requireAuth, async (req, res) => {
const userId = req.user.id;
const orders = await db.order.findMany({ where: ⟨3⟩ });
res.json({ orders });
}); router.post("/api/orders", requireAuth, async (req, res) => {
const ⟨5⟩ = req.body;
if (!items?.length) {
return res.status(400).json({ error: "items required" });
}
const order = await db.order.create({
data: ⟨7⟩,
});
res.status(201).json({ order });
}); export default router;
L’implémentation correspondante du gestionnaire de route :
// app/api/orders/route.ts
import ⟨0⟩ from “next/server”;
import ⟨1⟩ from “@/lib/db”;
import ⟨2⟩ from “@/lib/auth”;
export async function GET(req: NextRequest) {
const user = await getSessionUser(req);
if (!user) {
return NextResponse.json(⟪3⟩, ⟨4⟩);
} const orders = await db.order.findMany({ where: ⟨5⟩ });
return NextResponse.json(⟪6⟩);
}export async function POST(req: NextRequest) {
const user = await getSessionUser(req);
if (!user) {
return NextResponse.json(⟪7⟩, ⟨8⟩);
} const body = await req.json();
if (!body.items?.length) {
return NextResponse.json(⟪9⟩, { status: 400 }); } const order = await db.order.create({
data: { userId: user.id, items: body.items },
});
return NextResponse.json({ order }, { status: 201 });
}
Plusieurs détails méritent attention lors de la traduction des modèles Express en gestionnaires de routes :
Les segments dynamiques utilisent une syntaxe basée sur des dossiers plutôt que des préfixes en deux-points. Une route Express à /api/orders/:id devient app/api/orders/[id]/route.ts. Le paramètre est transmis en tant que deuxième argument à la fonction de gestionnaire : GET(req, { params }: { params: Promise<{ id: string }> }). Les versions actuelles de Next.js fournissent les paramètres sous forme de promesse, il faut donc attendre leur résolution avant de lire leur valeur.
Aucune chaîne de middleware par route n’existe. Le middleware requireAuth d’Express se transforme soit en une fonction utilitaire partagée appelée au début de chaque gestionnaire (comme montré dans l’exemple ci-dessus), soit, de préférence, en de la logique contenue dans middleware.ts (abordée dans une section ultérieure), afin que les gestionnaires de route individuels restent indifférents aux problèmes d’authentification.
Le parsing du corps nécessite des appels explicites — vous écrivez await req.json() plutôt que de compter sur express.json(). Aucun parsing automatique ne a lieu, ce qui améliore en réalité la clarté : vous évitez les limites inattendues de taille du corps imposées par un middleware global que vous avez configuré il y a des mois et oublié.
Les gestionnaires de route restent des fonctions ordinaires Node ou Edge. Si vous avez validé les entrées à l’aide de zod dans vos routes Express, ce code de validation est transféré sans modification.
Components serveur vs vos schémas React existants gérés côté client
Cet aspect prend les équipes par surprise plus que tout autre. Dans une architecture Express + React, chaque composant est par défaut un composant client : il s’affiche dans le navigateur, et lorsqu’il a besoin de données, il appelle votre API depuis useEffect ou à l’aide d’une bibliothèque de récupération de données telle que React Query.
// Ancien modèle : React rendu côté client qui communique avec Express
function OrderList() {
const [orders, setOrders] = useState<Order[] | null>(null);
useEffect(() => {
fetch("/api/orders", { credentials: "include" })
.then((r) => r.json())
.then((data) => setOrders(data.orders));
}, []); if (!orders) return <Spinner />;
return (
<ul>
{orders.map((o) => (
<li key={o.id}>{o.id} — ${o.total}</li>
))}
</ul>
);
}
L’App Router inverse cette configuration par défaut. Chaque composant est un composant serveur à moins que vous ne spécifiiez le contraire, ce qui signifie qu’il s’exécute sur le serveur, accède directement à votre base de données ou à vos services, et n’envoie jamais son JavaScript au client. Vous pouvez complètement omettre la route API pour les données appartenant à la page.
Vérifiez-le directement en effectuant une recherche :// app/orders/page.tsx — Composant serveur, sans "use client"
import { db } from "@/lib/db";
import { getSessionUser } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function OrdersPage() {
const user = await getSessionUser();
if (!user) redirect("/login"); // accès direct à la base de données, sans fetch, sans état de chargement, sans coût lié au bundle client
const orders = await db.order.findMany({
where: { userId: user.id }rver fournit de l’HTML qui contient déjà les données. Vous n’ajoutez "use client" que lorsque un composant nécessite de l’interactivité : un état, des effets, des gestionnaires d’événements ou des API réservées au navigateur : // app/orders/OrderFilters.tsx
"use client";
import React from "react";
import { useRouter, useSearchParams } from "next/navigation";export function OrderFilters() {
const router = useRouter();
const params = useSearchParams();
const [status, setStatus] = useState(params.get("status") ?? "all"); function apply(next: string) {
setStatus(next);
const url = new URLSearchParams(params);
url.set("status", next);
router.push(`/orders?${url.toString()}`);
} return (
<select value={status} onChange={(e) => apply(e.target.value)}>
<option value="all">Tous</option>
<option value="pending">En attente</option>
<option value="shipped">Expédié</option>
</select>
);
}
/code> Guideline pratique pour les équipes : poussez l’instruction "use client" aussi bas que possible dans l’arbre des composants. Conservez vos pages et vos layouts en tant que composants serveur ; appliquez cette directive uniquement aux composants feuille qui ont réellement besoin d’interactivité. Si vous ajoutez "use client" à chaque composant par habitude (ce qui arrive lors du transfert d’une application single-page sans reconsidérer l’architecture), vous ne bénéficierez d’aucun avantage avec App Router et votre modèle mental sera même pire que celui de la configuration initiale.
Remplacer votre middleware d’authentification Express par un middleware Next.js
Avec Express, le middleware d’authentification s’exécute pour chaque route au sein de votre processus Node.js. Next.js propose middleware.ts, qui intercepte les requêtes au niveau de la couche d’edge avant qu’elles n’atteignent une page ou un gestionnaire, servant ainsi de contrepartie architecturale :
// Ancien : middleware/auth.ts (Express)
import jwt from "jsonwebtoken";
export function requireAuth(req, res, next) {
const token = req.cookies.session;
if (!token) return res.status(401).json([0]); try {
req.user = jwt.verify(token, process.env.JWT_SECRET!);
next();
} catch {
res.status(401).json([1]);
}
}
// middleware.ts — se trouve à la racine du projet
import { Server } from "next/server";
import { verifyJWT } from "jose"; // compatible avec Edge, contrairement à jsonwebtoken
const PROTECTED_PREFIXES = [/dashboard/, /orders/, /api/orders/]; export async functionn middleware(req: NextRequest) {
const isProtected = PROTECTED_PREFIXES.some((p) =>
req.nextUrl.pathname.startsWith(p)
);
if (!isProtected) return NextResponse.next(); const token = req.cookies.get("session")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", req.url));
} try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
const { payload } = await jwtVerify(token, secret); // transmettre l’ID utilisateur vérifié vers le système suivant via une en-tête de requête
const headers = new Headers(req.headers);
headers.set("x-user-id", String(payload.sub));
return NextResponse.next({ request: { headers } });
} catch {
return NextResponse.redirect(new URL("/login", req.url));
}
}export const config = {
matcher: ["/dashboard/:path*", "/orders/:path*", "/api/orders/:path*"],
};
Deux
Problèmes qui entravent systématiquement les équipes en phase de migration :- Par défaut, le middleware s’exécute sur l’Edge runtime, et non sur Node.js. Toute bibliothèque dépendante des modules de base de Node.js —
jsonwebtoken, les clients de bases de données courants — fonctionnera mal ou tombera en panne silencieusement. Préférez des alternatives compatibles avec Edge :joseest le choix habituel pour les opérations JWT. Déplacez toutes les requêtes à la base de données dans des Server Components ou des Route Handlers où l’environnement complet de Node.js est disponible ; évitez absolument de les exécuter dans le middleware.
SELECT * FROM sessions WHERE id = ?. Limitez la logique des middleware à des opérations rapides et sans état, comme la vérification de la signature des tokens. Reportez les questions d’autorisation — « cet utilisateur peut-il voir cette commande ? » — au composant de page ou au gestionnaire lui-même, où l’accès à la base de données et les API Node.js sont pleinement disponibles.Schémas de récupération des données : Composants serveur vs votre approche actuelle basée sur les appels API
La plupart des architectures Express combinées à React suivent une séquence prévisible : le composant est chargé, il récupère des données via votre API, l’API interroge la base de données, les données au format JSON sont renvoyées au navigateur, puis le composant est mis à jour. Deux sauts réseau — du client au serveur, puis du serveur à la base de données — permettent d’obtenir des informations que le serveur possédait déjà.
Avec App Router, les opérations de lecture dans les composants serveur compressent ce processus en une seule étape : le serveur interroge la base de données et transmet directement au client un HTML contenant le résultat, comme illustré dans le code précédent de OrdersPage. Pour les opérations d’écriture, deux approches sont couramment utilisées : les Route Handlers lorsque vous avez besoin d’une API conventionnelle (par exemple, une interface REST publique), ou les Server Actions pour les mutations déclenchées par vos propres formulaires et interfaces utilisateur.
Les Server Actions s’écartent le plus nettement des conventions d’Express. Vous définissez une fonction qui s’exécute sur le serveur et vous l’appelez directement depuis un formulaire, sans créer de route API explicite :
// app/orders/actions.ts
"use server";
import { db } from "@/lib/db";
import { getSessionUser } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";export async function createOrder(formData: FormData) {
const user = await getSessionUser();
if (!user) redirect("/login"); const itemId = formData.get("itemId");
if (typeof itemId !== "string" || !itemId) {
throw new Error("l’identifiant de l’article est requis");
} await db.order.create({
data: { userId: user.id, items: [{ itemId, qty: 1 }] },
}); // réafficher la page des commandes avec de nouvelles données serveur — pas besoin de récupération client
revalidatePath("/orders");
}
// app/orders/NewOrderForm.tsx
import { createOrder } from "./actions";
export function NewOrderForm() {
return (
<form action={createOrder}>
<input type="text" name="itemId" placeholder="ID de l’article" required />
<button type="submit">Créer une commande</button>
</form>
);
}
Choix entre les actions serveur et les gestionnaires de route :
Utilisez un Route Handler lorsque l’endpoint doit rester stable et accessible depuis l’extérieur de cette application Next.js — un client mobile, une intégration tierce ou une API publique. Les Route Handlers vous fournissent des chemins URL explicites, des verbes HTTP ainsi qu’un contrat que vous contrôlez et pour lequel vous pouvez définir des versions.
Utilisez une Action serveur lorsque la mutation provient de vos propres formulaires et composants interactifs. Les Actions serveurs nécessitent moins de code générique et valident automatiquement à nouveau les données en cache via revalidatePath ou revalidateTag, éliminant ainsi la nécessité d’effectuer manuellement des mises à jour de cache après une appel fetch. Elles ne sont pas conçues pour des utilisateurs externes ni pour garantir la compatibilité vers l’arrière ; considérez-les comme des appels de procédure à distance internes.
Le test pratique : si vous devez documenter l’endpoint pour quelqu’un en dehors de votre équipe, faites-en un Route Handler. S’il existe uniquement pour alimenter un bouton ou un formulaire dans votre interface utilisateur, une Server Action est plus simple.
Différences de déploiement (Vercel vs ECS/EB)
Les équipes habituées à déployer Express sur ECS ou Elastic Beanstalk rencontrent un changement architectural fondamental avec Next.js sur Vercel. Une application Express fonctionne comme un processus Node à long terme : un seul processus gère de nombreuses requêtes, maintient des connexions de base de données actives, et présente des caractéristiques de mémoire et de démarrage prévisibles.
Next.js sur Vercel déploie les pages et les Route Handlers en tant que fonctions serverless ou edge distinctes. Chaque fonction démarre de manière indépendante, fonctionne selon ses propres limites d’exécution et ne part pas un pool de connexions de base de données persistant comme le fait un seul processus Express. Si vous instanciez un client Prisma de la même manière que dans Express, vous épuiserez votre limite de connexions à la base de données sous charge, car chaque appel de fonction peut créer sa propre connexion.
// lib/db.ts — schéma requis pour Prisma serverless
import { PrismaClient } from "@prisma/client";
const globalForPrisma = global as unknown as { prisma: PrismaClient };// réutiliser le client lors des appels successifs au lieu de créer un nouveau à chaque fois
export const db =
globalForPrisma.prisma ??
new PrismaClient({
// utiliser une chaîne de connexion gérée en pool (par ex. PgBouncer / Prisma Accelerate / RDS Proxy)
datasources: { db: { url: process.env.DATABASE_URL_POOLED } },
});if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;
Ce schéma permet de réutiliser le client lors des appels successifs au lieu de créer une nouvelle instance à chaque requête.
Si vous déployez Next.js sur ECS ou Elastic Beanstalk au lieu de Vercel, le framework fonctionne comme un serveur Node traditionnel. Exécutez next build suivi de next start, ou configurez le mode de sortie autonome pour générer des artefacts Docker plus légers. Vous conservez les avantages liés aux processus en cours d’exécution prolongée, mais vous renoncez au réseau edge automatique de Vercel, à la régénération statique incrémentale sur le CDN et aux déploiements en prévisualisation sans configuration. Vous devez configurer ces fonctionnalités vous-même ou accepter leur absence. Il s’agit d’un choix technique valable, et non d’un compromis ; de nombreuses équipes utilisent Next.js sur ECS précisément parce qu’elles possèdent déjà l’infrastructure et préfèrent ne pas répartir l’hébergement entre deux fournisseurs.
Allouez du temps à deux tâches de migration supplémentaires : les variables d’environnement (Next.js exige le préfixe NEXT_PUBLIC_ pour toute variable exposée au navigateur — vérifiez chaque variable que vous transmettez lors de la compilation React) et la configuration en temps de compilation par rapport au temps d’exécution (les valeurs intégrées dans une exportation statique se comportent différemment des valeurs lues au moment de la demande, contrairement à un processus Express unique où tout est géré en temps d’exécution).
Liste de contrôle pour la migration et pièges courants
Suivez ces étapes dans l’ordre :
- Déployez l’application Next.js en parallèle de votre serveur Express — laissez l’application existante inchangée pendant la configuration initiale.
- Convertissez d’abord les pages à fort volume de lecture et optimisées pour les moteurs de recherche — ces pages tirent le plus parti des composants serveur et présentent un risque de migration minimal.
middleware.ts à l’aide d’une bibliothèque de vérification de tokens compatible avec le runtime edge.next build && next start) — next dev masque certains erreurs qui ne se manifestent qu’en mode production, comme les violations de la frontière serveur/client.Erreurs courantes rencontrées lors de la migration :
- Importation de code réservé au serveur dans un composant client. Lorsqu’un composant
"use client"importe tout élément ayant accès àfs, au client de base de données ou aux secrets, la compilation échoue ou, pire encore, inclut ces secrets dans le JavaScript client. Installez le paquetserver-onlypour provoquer une erreur de compilation plutôt que de laisser fuir silencieusement les identifiants.
revalidatePath ou revalidateTag après une action serveur. Sans révalidation explicite, l’interface affiche des données obsolètes suite à une modification, car les composants serveur peuvent être mémorisés en cache."use client". Les habitudes héritées des applications à une seule page sont la raison principale pour laquelle une application Next.js ne parvient pas à surpasser l’original.Conclusion
Si vous restez incertain, commencez par une expérience ciblée : sélectionnez une page à fort volume de lecture et cruciale pour le SEO dans votre application actuelle, reconstruisez-la en tant que composant serveur qui interroge votre base de données ou appelle directement votre API Express existante, puis déployez-la sur une route que votre serveur Express ne gère pas. Comparez le temps nécessaire pour obtenir les premiers octets et la taille du bundle avant de migrer d’autres pages. Une fois cette approche validée, intégrez l’authentification dans middleware.ts, puis convertissez progressivement vos workflows les plus fréquentés en actions serveur, un par un — une migration progressive est plus sûre et plus pratique qu’une réécriture totale. Ce sont les équipes qui migrent l’ensemble du codebase avant d’avoir identifié où les composants serveur réduisent véritablement l’effort et où ils ne font qu’introduire une nouvelle couche conceptuelle sur un système déjà fonctionnel qui rencontrent des problèmes.
Lectures complémentaires
- Créer une interface de recherche React avec une gestion claire de l’état — Séparer les événements, les valeurs dérivées, la durée de vie des requêtes et l’affichage des résultats afin que la bonne réponse corresponde à la bonne requête.
- Déboguer RAG en identifiant la première étape qui a échoué — Distinguer les pannes liées à la source, à la récupération des données, à la génération de contenu et aux opérations afin que chaque expérience corrige un problème clairement identifié.
- Le rôle de pont de TypeScript 6 sur la voie menant à un compilateur natif TypeScript 7 — Découvrir comment TypeScript 6 met à jour les configurations par défaut, la résolution des modules et la syntaxe d’import pour préparer les bases de code au compilateur TypeScript 7, plus rapide et basé sur Go.