Changer le thème

Guide complet des API Routes Next.js : Route Handlers et bonnes pratiques

Easton editorial illustration: performance inspection lens

Vendredi après-midi, le product manager passe et demande : « On peut ajouter une API d’inscription utilisateur ? ». J’ouvre le dossier pages/api du projet, prêt à recopier l’ancienne recette, et le dossier est vide. Puis je me souviens : c’est un projet App Router — la façon d’écrire les API a complètement changé.

Dans la doc Next.js, le terme « Route Handlers » fait un peu peur — encore un concept nouveau. J’ai passé l’après-midi à lire la doc et des exemples pour comprendre ce qu’est route.ts et pourquoi on n’utilise plus req et res.

Si vous êtes aussi perdus sur les endpoints backend avec Next.js, cet article clarifie la situation. On compare Pages Router et App Router, puis des cas concrets pour traiter les requêtes, concevoir les réponses et gérer les erreurs proprement. À la fin, vous devriez pouvoir écrire vos APIs Next.js avec confiance.

Bases des API Routes : deux écritures, une vraie différence

L’ère Pages Router

Avant Next.js 13, on écrivait dans pages/api. Le style ressemblait à Express, avec les objets Node.js req et res :

// pages/api/hello.ts
import type { NextApiRequest, NextApiResponse } from 'next'

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  res.status(200).json({ message: 'Hello from Pages Router!' })
}

Avantage : prise en main rapide pour qui connaît Node.js ou Express. Inconvénient : APIs spécifiques Node.js — déploiement Edge plus compliqué.

L’ère App Router : Route Handlers

Avec l’App Router, l’API vit dans app/.../route.ts et utilise l’API Web Request et Response :

// app/api/hello/route.ts
export async function GET(request: Request) {
  return Response.json({ message: 'Hello from Route Handlers!' })
}

Au début, ça déroute. Pourquoi plus de req/res ? Les raisons sont solides :

  1. Standards Web : Request/Response natifs, code plus portable
  2. Meilleur typage : TypeScript mieux intégré
  3. Edge Runtime : déploiement Vercel Edge, Cloudflare Workers, latence réduite

Comparaison

CaractéristiquePages RouterApp Router
Emplacementpages/api/*app/*/route.ts
APINode.js req/resWeb Request/Response
Méthodes HTTPexport par défaut + req.methodexport nommé par méthode
Cachepas de cache par défautGET mis en cache par défaut

Au début je ne voyais pas l’intérêt. Après usage, le code est plus lisible — surtout pour séparer GET, POST, etc., sans une ribambelle de if (req.method === 'GET').

Route Handlers en pratique : créer et traiter les requêtes HTTP

Méthodes supportées

Route Handlers accepte GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Chaque méthode = une fonction exportée — la structure du fichier montre tout de suite ce que l’endpoint supporte.

Exemple complet de gestion d’utilisateurs :

// app/api/users/route.ts

// Liste des utilisateurs
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const page = searchParams.get('page') || '1'

  return Response.json({
    users: [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' }
    ],
    page: parseInt(page)
  })
}

// Créer un utilisateur
export async function POST(request: Request) {
  const body = await request.json()

  return Response.json({
    id: 3,
    name: body.name
  }, { status: 201 })
}

Récupérer les données de la requête

Plusieurs façons — au début je les mélangeais :

  1. Paramètres d’URL : request.url + objet URL
const { searchParams } = new URL(request.url)
const keyword = searchParams.get('q')
  1. Corps JSON : await request.json()
const body = await request.json()
console.log(body.email)
  1. FormData : await request.formData()
const formData = await request.formData()
const file = formData.get('avatar')
  1. En-têtes et cookies : depuis next/headers
import { headers, cookies } from 'next/headers'

export async function GET() {
  const headersList = headers()
  const cookieStore = cookies()

  const token = headersList.get('authorization')
  const userId = cookieStore.get('user_id')

  // ...
}
  1. Paramètres de route dynamique : second argument de la fonction
// app/api/users/[id]/route.ts
export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const userId = params.id
  return Response.json({ userId })
}

Piège : à partir de Next.js 15+, params peut être asynchrone (await params.id). En pratique TypeScript vous guidera.

Construire la réponse

JSON avec Response.json() :

export async function GET() {
  return Response.json({
    success: true,
    data: { message: 'Opération réussie' }
  })
}

Statut et en-têtes personnalisés :

export async function POST(request: Request) {
  const body = await request.json()

  if (!body.email) {
    return Response.json(
      { error: 'L\'e-mail est obligatoire' },
      { status: 400 }
    )
  }

  return Response.json(
    { id: 123, email: body.email },
    {
      status: 201,
      headers: {
        'X-Request-Id': 'abc-123',
        'Cache-Control': 'no-cache'
      }
    }
  )
}

Cas réel : inscription utilisateur

// app/api/auth/register/route.ts
import { headers } from 'next/headers'

export async function POST(request: Request) {
  const headersList = headers()
  const contentType = headersList.get('content-type')

  if (!contentType?.includes('application/json')) {
    return Response.json(
      { error: 'Veuillez envoyer les données au format JSON' },
      { status: 400 }
    )
  }

  const body = await request.json()
  const { username, email, password } = body

  if (!username || !email || !password) {
    return Response.json(
      { error: 'Nom d\'utilisateur, e-mail et mot de passe sont obligatoires' },
      { status: 400 }
    )
  }

  // const user = await db.user.create({ username, email, password })

  return Response.json({
    success: true,
    data: {
      id: 1,
      username,
      email
    }
  }, { status: 201 })
}

Ce modèle couvre en-têtes, JSON, validation, erreurs et succès — la base de la plupart des endpoints.

Bonnes pratiques de gestion d’erreurs

Try-catch : la bonne approche

Au début, un gros try-catch autour de tout :

// ❌ Déconseillé : un seul try-catch global
export async function POST(request: Request) {
  try {
    const body = await request.json()
    // logique métier...
    return Response.json({ success: true })
  } catch (error) {
    return Response.json({ error: 'Échec de l\'opération' }, { status: 500 })
  }
}

Problème : tout part en 500, le front n’a pas de détail. Mieux vaut traiter par étape :

// ✅ Recommandé : erreurs typées
export async function POST(request: Request) {
  let body

  try {
    body = await request.json()
  } catch (error) {
    return Response.json(
      { error: 'Format de requête invalide — vérifiez le JSON' },
      { status: 400 }
    )
  }

  if (!body.email || !body.password) {
    return Response.json(
      { error: 'E-mail et mot de passe obligatoires' },
      { status: 400 }
    )
  }

  try {
    const user = await db.user.create(body)
    return Response.json({ success: true, data: user })
  } catch (error) {
    if (error.code === 'P2002') {
      return Response.json(
        { error: 'Cet e-mail est déjà enregistré' },
        { status: 409 }
      )
    }

    console.error('Database error:', error)
    return Response.json(
      { error: 'Erreur serveur — réessayez plus tard' },
      { status: 500 }
    )
  }
}

Réponses d’erreur structurées

Formats hétérogènes (error, message, msg) compliquent le front. Format unifié :

interface ErrorResponse {
  success: false
  error: string
  code?: string
  details?: any
  requestId?: string
}

interface SuccessResponse<T> {
  success: true
  data: T
  requestId?: string
}

Helpers :

// lib/api-response.ts
import { nanoid } from 'nanoid'

export function successResponse<T>(data: T, status: number = 200) {
  return Response.json({
    success: true,
    data,
    requestId: nanoid()
  }, { status })
}

export function errorResponse(
  error: string,
  status: number = 500,
  code?: string,
  details?: any
) {
  const isDev = process.env.NODE_ENV === 'development'

  return Response.json({
    success: false,
    error,
    code,
    details: isDev ? details : undefined,
    requestId: nanoid()
  }, { status })
}

Usage :

// app/api/users/[id]/route.ts
import { successResponse, errorResponse } from '@/lib/api-response'

export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const userId = params.id

  try {
    const user = await db.user.findUnique({ where: { id: userId } })

    if (!user) {
      return errorResponse('Utilisateur introuvable', 404, 'USER_NOT_FOUND')
    }

    return successResponse(user)
  } catch (error) {
    return errorResponse(
      'Impossible de récupérer l\'utilisateur',
      500,
      'INTERNAL_ERROR',
      error
    )
  }
}

Erreurs attendues vs inattendues

  1. Attendues : paramètres invalides, ressource absente, droits insuffisants → 4xx
  2. Inattendues : base indisponible, service tiers, bug → 5xx + logs
export async function POST(request: Request) {
  const body = await request.json()

  if (!body.email?.includes('@')) {
    return errorResponse('Format d\'e-mail invalide', 400, 'INVALID_EMAIL')
  }

  try {
    const response = await fetch('https://api.example.com/verify', {
      method: 'POST',
      body: JSON.stringify({ email: body.email })
    })

    if (!response.ok) {
      return errorResponse('Échec de la vérification e-mail', 400, 'VERIFICATION_FAILED')
    }

    return successResponse({ verified: true })
  } catch (error) {
    console.error('Unexpected error:', error)

    return errorResponse(
      'Service temporairement indisponible',
      503,
      'SERVICE_UNAVAILABLE'
    )
  }
}

Ne pas fuiter d’informations sensibles

Retourner le message brut de la base expose parfois le schéma. À faire :

try {
  const user = await db.user.create(body)
  return successResponse(user)
} catch (error) {
  console.error('Database error:', {
    error,
    userId: request.headers.get('user-id'),
    timestamp: new Date().toISOString()
  })

  return errorResponse(
    'Échec de la création — réessayez plus tard',
    500,
    'CREATE_USER_FAILED'
  )
}

Dev vs prod :

const isDev = process.env.NODE_ENV === 'development'

return Response.json({
  success: false,
  error: 'Échec de l\'opération',
  stack: isDev ? error.stack : undefined,
  details: isDev ? error : undefined
}, { status: 500 })

Conception du format de réponse

Principes REST

Pas une religion, mais des règles qui clarifient l’API :

  1. Méthodes HTTP : GET lire, POST créer, PUT/PATCH modifier, DELETE supprimer
  2. URL = ressources : /api/users, /api/users/123, /api/users/123/posts
  3. Codes HTTP : 200 OK, 201 créé, 400 client, 401 non connecté, 403 interdit, 404 absent, 500 serveur

Exemple CRUD utilisateurs :

// app/api/users/route.ts
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const page = parseInt(searchParams.get('page') || '1')
  const limit = parseInt(searchParams.get('limit') || '20')

  const users = await db.user.findMany({
    skip: (page - 1) * limit,
    take: limit
  })

  return Response.json({ success: true, data: users })
}

export async function POST(request: Request) {
  const body = await request.json()
  const user = await db.user.create({ data: body })

  return Response.json(
    { success: true, data: user },
    { status: 201 }
  )
}

// app/api/users/[id]/route.ts
export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const user = await db.user.findUnique({ where: { id: params.id } })

  if (!user) {
    return Response.json(
      { success: false, error: 'Utilisateur introuvable' },
      { status: 404 }
    )
  }

  return Response.json({ success: true, data: user })
}

export async function PATCH(
  request: Request,
  { params }: { params: { id: string } }
) {
  const body = await request.json()
  const user = await db.user.update({
    where: { id: params.id },
    data: body
  })

  return Response.json({ success: true, data: user })
}

export async function DELETE(
  request: Request,
  { params }: { params: { id: string } }
) {
  await db.user.delete({ where: { id: params.id } })

  return Response.json({ success: true, data: null })
}

Format unifié et pagination

type ApiResponse<T> =
  | { success: true; data: T }
  | { success: false; error: string; code?: string }

interface PaginatedResponse<T> {
  success: true
  data: T[]
  pagination: {
    page: number
    limit: number
    total: number
    totalPages: number
  }
}

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const page = parseInt(searchParams.get('page') || '1')
  const limit = parseInt(searchParams.get('limit') || '20')

  const [users, total] = await Promise.all([
    db.user.findMany({ skip: (page - 1) * limit, take: limit }),
    db.user.count()
  ])

  return Response.json({
    success: true,
    data: users,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit)
    }
  })
}

Types TypeScript partagés

// types/api.ts
export interface User {
  id: string
  username: string
  email: string
  createdAt: string
}

export interface CreateUserRequest {
  username: string
  email: string
  password: string
}

export interface CreateUserResponse {
  success: true
  data: User
}

// types/api-client.ts
import type { CreateUserRequest, CreateUserResponse } from './api'

export async function createUser(data: CreateUserRequest) {
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  })

  const result: CreateUserResponse = await response.json()

  if (!result.success) {
    throw new Error(result.error)
  }

  return result.data
}

Problèmes fréquents

Cache par défaut sur GET

Après déploiement, les données utilisateur restaient périmées : GET est mis en cache par défaut dans l’App Router.

// app/api/users/route.ts
export const dynamic = 'force-dynamic'

export async function GET() {
  const users = await db.user.findMany()
  return Response.json({ success: true, data: users })
}

Autres options :

export const revalidate = 0

export async function GET() {
  const users = await db.user.findMany()

  return Response.json(
    { success: true, data: users },
    {
      headers: {
        'Cache-Control': 'no-store, max-age=0'
      }
    }
  )
}

Pour des données quasi statiques (pays, catégories) :

export const revalidate = 3600

export async function GET() {
  const countries = await db.country.findMany()
  return Response.json({ success: true, data: countries })
}

404 après déploiement

Causes habituelles :

  1. Fichier nommé route.ts ou route.js, pas api.ts
  2. route.ts pas au même niveau que page.tsx
  3. Fichier non versionné (vérifier .gitignore)
# ✅ Structure correcte
app/
  api/
    users/
      route.ts
    users/
      [id]/
        route.ts

# ❌ Incorrect
app/
  api/
    users.ts
  users/
    page.tsx
    route.ts

Vérifier aussi next.config.js :

module.exports = {
  // Ne pas exclure app par erreur
}

redirect() dans un try-catch

redirect() lève une erreur spéciale ; un try-catch l’attrape et bloque la redirection :

import { redirect } from 'next/navigation'

// ❌
export async function GET() {
  try {
    const isLoggedIn = await checkAuth()
    if (!isLoggedIn) {
      redirect('/login')
    }
    return Response.json({ success: true })
  } catch (error) {
    return Response.json({ error: 'Échec' }, { status: 500 })
  }
}

// ✅
export async function GET() {
  const isLoggedIn = await checkAuth()
  if (!isLoggedIn) {
    redirect('/login')
  }

  try {
    const data = await fetchData()
    return Response.json({ success: true, data })
  } catch (error) {
    return Response.json({ error: 'Échec' }, { status: 500 })
  }
}

En Route Handlers, un 401 laisse souvent le front gérer la navigation.

Quand ne pas utiliser Route Handlers

Les Server Components peuvent appeler la base sans HTTP intermédiaire :

// ❌ API + fetch interne
export async function GET() {
  const posts = await db.post.findMany()
  return Response.json({ success: true, data: posts })
}

async function BlogPage() {
  const res = await fetch('http://localhost:3000/api/posts')
  const { data } = await res.json()
  return <div>{/* liste */}</div>
}

// ✅ Requête directe
async function BlogPage() {
  const posts = await db.post.findMany()
  return <div>{/* liste */}</div>
}

Besoin de Route Handlers : API mobile/tiers, webhooks, mutations client, upload, orchestration externe.

Pas besoin : lecture en Server Component, formulaires simples (Server Actions), navigation interne.

Techniques avancées

Validation avec Zod

import { z } from 'zod'

const createUserSchema = z.object({
  username: z.string().min(3).max(20),
  email: z.string().email(),
  password: z.string().min(8),
  age: z.number().min(18).optional()
})

export async function POST(request: Request) {
  const body = await request.json()
  const result = createUserSchema.safeParse(body)

  if (!result.success) {
    return Response.json({
      success: false,
      error: 'Échec de la validation',
      details: result.error.errors
    }, { status: 400 })
  }

  const user = await db.user.create({ data: result.data })
  return Response.json({ success: true, data: user }, { status: 201 })
}
type CreateUserInput = z.infer<typeof createUserSchema>

Pattern middleware

// lib/middleware.ts
type RouteHandler = (request: Request, context: any) => Promise<Response>

export function withAuth(handler: RouteHandler): RouteHandler {
  return async (request, context) => {
    const token = request.headers.get('authorization')

    if (!token) {
      return Response.json(
        { success: false, error: 'Non connecté' },
        { status: 401 }
      )
    }

    const user = await verifyToken(token)

    if (!user) {
      return Response.json(
        { success: false, error: 'Token invalide' },
        { status: 401 }
      )
    }

    context.user = user
    return handler(request, context)
  }
}

export function withLogging(handler: RouteHandler): RouteHandler {
  return async (request, context) => {
    const start = Date.now()
    const { method, url } = request

    console.log(`[${method}] ${url} - début`)

    const response = await handler(request, context)

    console.log(`[${method}] ${url} - fin (${Date.now() - start}ms)`)
    return response
  }
}

// app/api/profile/route.ts
import { withAuth, withLogging } from '@/lib/middleware'

async function getProfile(request: Request, context: any) {
  const user = context.user
  return Response.json({ success: true, data: user })
}

export const GET = withLogging(withAuth(getProfile))

Avec validation :

export function withValidation<T>(
  schema: z.Schema<T>,
  handler: (request: Request, data: T, context: any) => Promise<Response>
): RouteHandler {
  return async (request, context) => {
    const body = await request.json()
    const result = schema.safeParse(body)

    if (!result.success) {
      return Response.json({
        success: false,
        error: 'Échec de la validation',
        details: result.error.errors
      }, { status: 400 })
    }

    return handler(request, result.data, context)
  }
}

export const POST = withAuth(
  withValidation(createUserSchema, async (request, data, context) => {
    const user = await db.user.create({ data })
    return Response.json({ success: true, data: user }, { status: 201 })
  })
)

Edge Runtime

export const runtime = 'edge'

export async function GET() {
  return Response.json({ message: 'Hello from Edge!' })
}

Avantages : latence mondiale. Limites : pas de fs/path, bases via HTTP (Prisma Data Proxy, PlanetScale), taille de bundle limitée.

Edge : API simples, lecture intensive, faible latence globale.

Node.js : base classique, écosystème Node, logique lourde.

La plupart de mes projets restent sur Node.js Runtime par défaut.

Conclusion

Récapitulatif :

  • Évolution : de req/res vers Request/Response
  • Route Handlers : exports par méthode HTTP
  • Requêtes : URL, JSON, FormData, headers, cookies, params dynamiques
  • Erreurs : 4xx vs 5xx, format unifié, pas de fuite de détails
  • Réponses : REST, codes sémantiques, types partagés
  • Pièges : cache GET, 404 au déploiement, redirect dans try-catch, sur-utilisation des Route Handlers

Le changement Pages → App Router demande un temps d’adaptation, puis le code devient plus clair et plus sûr au typage.

Prochaines étapes :

  1. Réécrire un endpoint existant en Route Handlers
  2. Créer un template maison (erreurs + format de réponse)
  3. Suivre la doc officielle (Server Actions, Middleware, etc.)

Il n’y a pas de solution unique — adaptez à votre équipe. Si cet article vous aide, gardez-le sous la main. Bon développement Next.js !

FAQ

Quelle est la différence principale entre Route Handlers et les API Routes du Pages Router ?
Trois points : 1) Route Handlers utilisent l'API Web Request/Response, le Pages Router utilise req/res Node.js ; 2) chaque méthode HTTP s'exporte à part, sans tester req.method ; 3) GET est mis en cache par défaut — ajouter dynamic='force-dynamic' pour désactiver.
Pourquoi mon GET ne renvoie pas les données les plus récentes ?
Dans l'App Router, GET est mis en cache par défaut. Solution : export const dynamic = 'force-dynamic' dans route.ts, ou Cache-Control: no-store dans les en-têtes. Réserver le cache aux données vraiment statiques.
Quand utiliser Route Handlers plutôt que Server Components ?
Route Handlers : appels API externes, webhooks, mutations depuis composants client, upload de fichiers. Sinon : Server Components pour lire la base, Server Actions pour des formulaires simples.
Comment gérer proprement les erreurs d'API ?
Séparer 4xx (attendu) et 5xx (inattendu), format unifié { success, error, code, requestId }. try-catch par opération, pas un seul bloc global. En production, ne pas exposer les détails sensibles — les logger.
Après déploiement, toutes les API renvoient 404 alors qu'en local ça marche ?
Vérifier : 1) le fichier doit s'appeler route.ts ou route.js ; 2) route.ts ne peut pas être dans le même dossier que page.tsx ; 3) fichier versionné et next.config.js n'exclut pas app. Erreur fréquente : nom ou emplacement du fichier.

12 min de lecture · Publié le: 5 janv. 2026 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog