Route Handlers: écrire une API dans Next.js
1. Aperçu
Dans l’App Router de Next.js, vous n’avez pas besoin de configurer un serveur Express séparé pour avoir une API. Les Route Handlers vous permettent d’écrire des endpoints HTTP directement dans le dossier app, en utilisant l’API Web standard Request et Response.
Ce tutoriel construit une petite API de gestion de commandes (Order). Vous apprendrez à définir les méthodes HTTP, lire la query string et le body, gérer les segments dynamiques, travailler avec les headers et les cookies, contrôler la mise en cache et écrire des tests avec Vitest. Les exemples utilisent Next.js 15+ avec TypeScript.
2. Votre premier Route Handler
Un Route Handler est un fichier nommé route.ts (ou route.js) situé dans app. Le chemin du dossier détermine l’URL. Chaque méthode HTTP est une fonction export avec un nom en majuscules.
Commençons par un simple « dépôt » de données en mémoire :
// lib/orders.ts
export type Order = {
id: string;
customer: string;
total: number;
};
const orders: Order[] = [
{ id: "1", customer: "An", total: 250_000 },
{ id: "2", customer: "Bình", total: 1_200_000 },
];
export const orderStore = {
findAll: () => [...orders],
findById: (id: string) => orders.find((o) => o.id === id),
create: (data: Omit<Order, "id">): Order => {
const order = { id: String(orders.length + 1), ...data };
orders.push(order);
return order;
},
remove: (id: string) => {
const index = orders.findIndex((o) => o.id === id);
if (index === -1) return false;
orders.splice(index, 1);
return true;
},
};
Créons maintenant l’endpoint GET /api/orders :
// app/api/orders/route.ts
import { orderStore } from "@/lib/orders";
export async function GET() {
return Response.json(orderStore.findAll());
}
Explication :
- Le fichier se trouve à
app/api/orders/route.ts, donc l’URL est/api/orders. Le préfixeapiest simplement une convention. Next.js ne l’impose pas. export async function GET()traite les requêtesGET. Next.js retourne automatiquement405 Method Not Allowedpour les méthodes que vous n’exportez pas.Response.json(...)est l’API Web standard. Elle sérialise les données et définit le headerContent-Type: application/json.
Exécutez npm run dev, puis testez :
curl http://localhost:3000/api/orders
[{"id":"1","customer":"An","total":250000},{"id":"2","customer":"Bình","total":1200000}]
Note : Vous ne pouvez pas avoir à la fois
route.tsetpage.tsxdans le même dossier. Par exemple,app/orders/page.tsxetapp/orders/route.tsentreraient en conflit. C’est pourquoi les API sont généralement regroupées dansapp/api.
3. Lire la query string et le body
3.1. Query string
Le paramètre du handler est une Request standard. Pour lire la query string, vous créez une URL à partir de request.url :
// app/api/orders/route.ts
import { orderStore } from "@/lib/orders";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const minTotal = Number(searchParams.get("minTotal") ?? 0);
const result = orderStore.findAll().filter((o) => o.total >= minTotal);
return Response.json(result);
}
Appeler /api/orders?minTotal=500000 retournera uniquement la commande de Bình :
[{"id":"2","customer":"Bình","total":1200000}]
Si vous le souhaitez, vous pouvez déclarer le paramètre comme NextRequest (de next/server) et utiliser request.nextUrl.searchParams. Ici, j’utilise Request standard car c’est plus facile à créer dans les tests.
3.2. Body JSON et validation
Ne faites pas confiance aux données envoyées par le client. Utilisez zod pour valider le body. Installez la bibliothèque :
npm install zod
Ajoutez un handler POST au même fichier :
// app/api/orders/route.ts (suite)
import { z } from "zod";
const CreateOrderSchema = z.object({
customer: z.string().min(1),
total: z.number().positive(),
});
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "Body n'est pas un JSON valide" }, { status: 400 });
}
const parsed = CreateOrderSchema.safeParse(body);
if (!parsed.success) {
return Response.json(
{ error: "Données invalides", issues: parsed.error.issues },
{ status: 422 },
);
}
const order = orderStore.create(parsed.data);
return Response.json(order, {
status: 201,
headers: { Location: `/api/orders/${order.id}` },
});
}
Explication :
request.json()lève une exception si le body est vide ou mal formé. C’est pourquoi nous le wrappons danstry/catchet retournons400.safeParsene lève pas d’exception. Elle retourne{ success, data }ou{ success, error }. Les données mal typées recevront422.- Le deuxième paramètre de
Response.jsonvous permet de définirstatusetheaders. Ici, nous retournons201 Createdavec un headerLocation.
curl -i -X POST http://localhost:3000/api/orders \
-H "Content-Type: application/json" \
-d '{"customer":"Chi","total":99000}'
HTTP/1.1 201 Created
location: /api/orders/3
content-type: application/json
{"id":"3","customer":"Chi","total":99000}
Avec un formulaire HTML, utilisez await request.formData(). Avec des données brutes, utilisez await request.text(). Notez que le body ne peut être lu qu’une seule fois.
4. Segment dynamique
Pour avoir GET /api/orders/:id, créez un dossier avec un nom entre crochets :
// app/api/orders/[id]/route.ts
import { orderStore } from "@/lib/orders";
type Context = { params: Promise<{ id: string }> };
export async function GET(_request: Request, { params }: Context) {
const { id } = await params;
const order = orderStore.findById(id);
if (!order) {
return Response.json({ error: `Commande ${id} introuvable` }, { status: 404 });
}
return Response.json(order);
}
export async function DELETE(_request: Request, { params }: Context) {
const { id } = await params;
const removed = orderStore.remove(id);
return new Response(null, { status: removed ? 204 : 404 });
}
Explication :
- Le deuxième paramètre contient
params. À partir de Next.js 15,paramsest une Promise, vous devez donc l’await. DELETEretourne204 No Contentavec un bodynull. Vous ne devez pas utiliserResponse.jsondans ce cas, car204ne doit pas avoir de body.- Les valeurs dans
paramssont toujours desstring. Si l’ID est un nombre, vous devez le convertir et le valider vous-même.
Vous pouvez également utiliser un catch-all [...slug] (qui reçoit string[]) ou un catch-all optionnel [[...slug]].
Note : L’ancien code qui écrit
params.idsansawaitrecevra un avertissement ou une erreur sur Next.js 15+. Lors de la mise à jour à partir de Next.js 14, c’est le problème le plus courant.
5. Headers, cookies et redirection
Vous pouvez lire les headers directement à partir de request.headers. De plus, Next.js fournit les fonctions cookies() et headers() de next/headers. Les deux sont des fonctions async.
// app/api/me/route.ts
import { cookies, headers } from "next/headers";
export async function GET() {
const cookieStore = await cookies();
const headerList = await headers();
const session = cookieStore.get("session")?.value;
if (!session) {
return Response.json({ error: "Non connecté" }, { status: 401 });
}
return Response.json({
session,
userAgent: headerList.get("user-agent"),
});
}
export async function DELETE() {
const cookieStore = await cookies();
cookieStore.delete("session");
return new Response(null, { status: 204 });
}
cookieStore.get(...)est utilisé pour lire un cookie.setetdeletene fonctionnent que dans un Route Handler ou une Server Action, pas dans un Server Component.- Utiliser
cookies()ouheaders()rend la route dynamique, c’est-à-dire rendue à chaque requête.
Si vous avez besoin de rediriger, utilisez Response.redirect(new URL("/login", request.url), 307) ou la fonction redirect() de next/navigation.
Pour CORS, vous définissez les headers vous-même et exportez un handler OPTIONS pour la requête préalable :
// app/api/public/route.ts
const corsHeaders = {
"Access-Control-Allow-Origin": "https://shop.example.com",
"Access-Control-Allow-Methods": "GET, OPTIONS",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
export async function GET() {
return Response.json({ status: "ok" }, { headers: corsHeaders });
}
6. Mise en cache
C’est la partie qui confond le plus de gens. À partir de Next.js 15, les handlers GET ne sont plus mis en cache par défaut. Chaque requête réexécute la fonction. Les autres méthodes comme POST, PUT, DELETE ne sont jamais mises en cache.
Si les données changent rarement et que vous voulez pré-construire une version statique, activez-le avec la configuration du segment de route :
// app/api/categories/route.ts
export const dynamic = "force-static";
export const revalidate = 3600; // revalide au maximum toutes les heures
export async function GET() {
const categories = ["Électronique", "Livres", "Mode"];
return Response.json({ categories, generatedAt: new Date().toISOString() });
}
dynamic = "force-static"force la route à être rendue au moment de la build. Le résultat est ensuite servi comme un fichier statique.revalidate = 3600permet à Next.js de régénérer la réponse au maximum une fois par heure (ISR).- Lors de l’exécution de
next dev, la route s’exécute toujours à chaque requête. Pour voirgeneratedAtrester constant, vous devez exécuternext build && next start.
Note : Si une route
force-staticlitcookies(),headers()ou la query string, ces valeurs seront vides au moment de la build. N’utilisez pas static pour les endpoints qui dépendent de l’utilisateur.
7. Tester les Route Handlers avec Vitest
Un Route Handler n’est qu’une fonction async qui reçoit une Request et retourne une Response, vous pouvez donc l’appeler directement sans démarrer un serveur. Installez Vitest :
npm install -D vitest vite-tsconfig-paths
// vitest.config.ts
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()],
test: { environment: "node" },
});
Le plugin vite-tsconfig-paths aide l’alias @/lib/... à fonctionner dans les tests. Maintenant, écrivez les tests :
// app/api/orders/route.test.ts
import { describe, it, expect } from "vitest";
import { GET, POST } from "./route";
function jsonRequest(body: unknown) {
return new Request("http://localhost/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
describe("/api/orders", () => {
it("filtre les commandes selon minTotal", async () => {
const res = await GET(new Request("http://localhost/api/orders?minTotal=500000"));
const data = await res.json();
expect(res.status).toBe(200);
expect(data.every((o: { total: number }) => o.total >= 500_000)).toBe(true);
});
it("crée une commande valide et retourne 201", async () => {
const res = await POST(jsonRequest({ customer: "Dũng", total: 150_000 }));
expect(res.status).toBe(201);
expect(res.headers.get("Location")).toMatch(/^\/api\/orders\/\d+$/);
expect(await res.json()).toMatchObject({ customer: "Dũng", total: 150_000 });
});
it("rejette un total négatif avec 422", async () => {
const res = await POST(jsonRequest({ customer: "Dũng", total: -1 }));
expect(res.status).toBe(422);
});
});
npx vitest run
✓ app/api/orders/route.test.ts (3 tests)
Test Files 1 passed (1)
Tests 3 passed (3)
Si le handler utilise cookies() ou headers() de next/headers, l’appel direct lèvera une exception car le contexte de requête de Next.js est absent. Vous avez alors deux options : simuler le module avec vi.mock("next/headers", ...), ou lire les valeurs de request.headers pour que le code soit plus facile à tester.
8. Pièges courants et quand ne pas utiliser
Voici quelques pièges courants :
- Fetcher un Route Handler à partir d’un Server Component. Appeler
fetch("/api/orders")dans un Server Component crée une boucle HTTP inutile. Lors du build, cet appel échoue car le serveur n’est pas encore en cours d’exécution. À la place, appelez directementorderStore.findAll()ou la couche de requête de données. - Utiliser les Route Handlers pour les mutations à partir de formulaires internes. Si seule l’interface utilisateur de votre application y accède, les Server Actions sont plus propres. Elles offrent la sécurité des types de bout en bout et s’intègrent avec
revalidatePath. - Stocker l’état dans une variable de module, comme
orderStoreci-dessus, lors du déploiement serverless. Chaque instance a sa propre mémoire et peut être redémarrée à tout moment. L’exemple ci-dessus est à titre illustratif. En réalité, vous devez utiliser une base de données. - Oublier que le body ne peut être lu qu’une fois. Si vous devez le lire deux fois, utilisez
request.clone(). - Retourner des erreurs sans structure. Une exception non interceptée devient
500avec une page d’erreur HTML. Retournez toujours du JSON avec unstatusexplicite.
Les Route Handlers sont appropriés lorsque vous avez besoin :
- D’un endpoint pour un client externe, comme une application mobile ou un partenaire.
- D’un webhook pour recevoir des données, par exemple de Stripe ou GitHub.
- D’un endpoint qui retourne des fichiers, du RSS ou des données en streaming.
- D’un endpoint que le code côté client appelle avec
fetch.
9. Conclusion
Les Route Handlers transforment chaque fichier route.ts en un endpoint HTTP, utilisant les standards Web Request/Response. Dans ce tutoriel, vous avez :
- Défini les méthodes
GET,POST,DELETE. - Lu la query string et le body, validé le body avec
zod. - Géré les segments dynamiques avec
paramscomme une Promise. - Travaillé avec les headers, les cookies et CORS.
- Activé la mise en cache statique si nécessaire.
- Testé le handler directement avec Vitest sans démarrer un serveur.
Le principe clé à retenir : utilisez les Route Handlers pour les API publiques ou les webhooks. Pour les données internes, interrogez directement dans un Server Component. Pour les mutations à partir de formulaires de votre application, utilisez les Server Actions.