frontend / nextjs
Route Handlers (API routes)
Explication
Ce que vous allez apprendre
- Créer un
route.tsexportant des fonctions nommées par verbe HTTP (GET,POST,DELETE...) - Lire les query params, le corps JSON et les cookies d'une requête avec
NextRequest - Répondre avec
NextResponse.json, un status code précis et des cookies - Streamer une réponse volumineuse avec un
ReadableStream - Choisir entre une Server Action et un Route Handler selon le besoin réel
Dans quel contexte ?
Un service de paiement tiers (Stripe) doit notifier l'application dès qu'un paiement est confirmé, en envoyant une requête POST vers /api/webhooks/stripe. Une application mobile développée par une autre équipe doit aussi pouvoir lire le catalogue produit en JSON via GET /api/products. Aucun de ces deux besoins ne correspond à une Server Action (qui suppose un appel depuis un composant React) : ce sont des routes HTTP classiques, exactement ce que couvrent les Route Handlers de cette leçon.
Repartons de ce qu'on sait déjà
Les Server Actions vues dans la leçon précédente couvrent bien un cas : les mutations déclenchées depuis un composant React. Mais elles ne suffisent pas partout.
Certains besoins réclament une vraie route HTTP classique. Un webhook envoyé par un service externe (Stripe, un CMS), un client mobile qui consomme une API JSON, ou une réponse dans un format non-HTML (CSV, fichier binaire). C'est exactement le rôle des Route Handlers.
Voyons d'abord la structure de base. Un fichier route.ts placé dans app/api/... joue le rôle de "contrôleur" : chaque verbe HTTP (GET, POST, PUT, DELETE, PATCH) est une fonction exportée séparément, portant exactement ce nom.
Next.js s'occupe du routage à ta place. Il appelle automatiquement la bonne fonction selon la méthode de la requête entrante, sans que tu aies à router manuellement toi-même.
Deux objets facilitent le travail au quotidien. NextRequest et NextResponse étendent les objets Web standards (Request/Response) avec des commodités : lecture facile des paramètres de requête, gestion typée des cookies.
| Verbe HTTP | Fonction exportée | Usage typique |
|---|---|---|
| GET | export async function GET() | Lire une ressource ou une liste |
| POST | export async function POST() | Créer une ressource, recevoir un webhook |
| PATCH/PUT | export async function PATCH() | Mettre à jour une ressource existante |
| DELETE | export async function DELETE() | Supprimer une ressource |
Prérequis
Cette leçon suppose que tu es à l'aise avec les routes dynamiques ([id]) vues précédemment : un Route Handler peut lui aussi vivre dans un dossier [id]/route.ts.
Une fois les bases posées, voyons un besoin plus avancé : les réponses volumineuses. Pour un export CSV de milliers de lignes ou un flux d'événements en temps réel, attendre que TOUTES les données soient prêtes avant d'envoyer quoi que ce soit est inefficace.
La solution : le streaming. Un ReadableStream envoie la réponse morceau par morceau, au fur et à mesure qu'elle est générée — le client commence à recevoir des données avant même que le traitement serveur soit terminé.
Les pièges courants avant de te lancer :
- Oublier de valider le corps de la requête (
request.json()) avant de l'utiliser : un payload malformé plante silencieusement sans validation explicite.
Piège fréquent
Un POST /api/products qui fait directement db.insert("products").values(body) sans valider body accepte n'importe quel champ envoyé par le client, y compris des champs qu'il ne devrait pas pouvoir définir (comme un id ou un ownerId arbitraire). Valide toujours la forme exacte attendue avant d'écrire en base.
- Croire qu'une route
GETn'est jamais mise en cache : comme les pages, elle peut l'être viadynamic/revalidatesi son contenu ne dépend pas de paramètres variables. - Confondre le rôle des Route Handlers (API/webhooks/formats non-HTML) avec celui des Server Actions (mutations appelées depuis un composant React) : les deux coexistent, avec des usages différents.
Commandes & code
Route Handlers
// app/api/products/route.ts — gère /api/products
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const category = searchParams.get("category");
const products = await db.query.products.findMany({
where: category ? (p, { eq }) => eq(p.category, category) : undefined,
});
return NextResponse.json(products);
}
export async function POST(request: NextRequest) {
const body = await request.json();
if (!body.name || typeof body.price !== "number") {
return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
}
const product = await db.insert("products").values(body).returning();
return NextResponse.json(product, { status: 201 });
}// app/api/products/[id]/route.ts — segment dynamique dans une API route
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const product = await db.query.products.findFirst({ where: (p, { eq }) => eq(p.id, id) });
if (!product) {
return NextResponse.json({ error: "Introuvable" }, { status: 404 });
}
return NextResponse.json(product);
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
await db.delete("products").where({ id });
return new NextResponse(null, { status: 204 });
}// Headers, cookies et status personnalisés
export async function GET(request: NextRequest) {
const token = request.cookies.get("session")?.value;
const userAgent = request.headers.get("user-agent");
const response = NextResponse.json({ ok: true, userAgent });
response.cookies.set("last-seen", new Date().toISOString(), {
httpOnly: true,
secure: true,
maxAge: 60 * 60 * 24,
});
return response;
}// app/api/export/route.ts — réponse streamée (CSV volumineux, SSE, etc.)
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
controller.enqueue(encoder.encode("id,name,price\n"));
const products = await db.query.products.findMany();
for (const p of products) {
controller.enqueue(encoder.encode(`${p.id},${p.name},${p.price}\n`));
}
controller.close();
},
});
return new NextResponse(stream, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": "attachment; filename=export.csv",
},
});
}// Route Handlers en cache statique : ajouter "export const dynamic" si nécessaire
export const dynamic = "force-static"; // utile pour des routes GET sans param de requête
export const revalidate = 3600;Résumé
- Un
route.tsexporte des fonctions nommées par verbe HTTP :GET,POST,PUT,DELETE,PATCH. NextRequest/NextResponseétendent l'API Web standardRequest/Response.ReadableStreampermet de streamer des réponses volumineuses.- Les Route Handlers GET sont cachables comme des pages statiques via
dynamic/revalidate.
Exercices pratiques
Mission : le webhook Stripe qui accepte n'importe quoi
Objectif : Sécuriser un Route Handler POST en validant son payload et choisir correctement entre Server Action et Route Handler.
Contexte
app/api/products/route.ts expose un POST qui fait directement db.insert("products").values(body) sans jamais vérifier le contenu de body — un client pourrait envoyer un champ id ou ownerId arbitraire. Par ailleurs, un développeur hésite : pour un webhook Stripe qui doit notifier l'app d'un paiement confirmé, doit-il écrire une Server Action ou un Route Handler ?