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éfixe api est simplement une convention. Next.js ne l’impose pas.
  • export async function GET() traite les requêtes GET. Next.js retourne automatiquement 405 Method Not Allowed pour 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 header Content-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.ts et page.tsx dans le même dossier. Par exemple, app/orders/page.tsx et app/orders/route.ts entreraient en conflit. C’est pourquoi les API sont généralement regroupées dans app/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 dans try/catch et retournons 400.
  • safeParse ne lève pas d’exception. Elle retourne { success, data } ou { success, error }. Les données mal typées recevront 422.
  • Le deuxième paramètre de Response.json vous permet de définir status et headers. Ici, nous retournons 201 Created avec un header Location.
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, params est une Promise, vous devez donc l’await.
  • DELETE retourne 204 No Content avec un body null. Vous ne devez pas utiliser Response.json dans ce cas, car 204 ne doit pas avoir de body.
  • Les valeurs dans params sont toujours des string. 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.id sans await recevra 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. set et delete ne fonctionnent que dans un Route Handler ou une Server Action, pas dans un Server Component.
  • Utiliser cookies() ou headers() 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 = 3600 permet à 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 voir generatedAt rester constant, vous devez exécuter next build && next start.

Note : Si une route force-static lit cookies(), 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 directement orderStore.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 orderStore ci-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 500 avec une page d’erreur HTML. Retournez toujours du JSON avec un status explicite.

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 params comme 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.

nextjsroute-handlersapitypescriptapp-router