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

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 :
- Standards Web :
Request/Responsenatifs, code plus portable - Meilleur typage : TypeScript mieux intégré
- Edge Runtime : déploiement Vercel Edge, Cloudflare Workers, latence réduite
Comparaison
| Caractéristique | Pages Router | App Router |
|---|---|---|
| Emplacement | pages/api/* | app/*/route.ts |
| API | Node.js req/res | Web Request/Response |
| Méthodes HTTP | export par défaut + req.method | export nommé par méthode |
| Cache | pas de cache par défaut | GET 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 :
- Paramètres d’URL :
request.url+ objetURL
const { searchParams } = new URL(request.url)
const keyword = searchParams.get('q')
- Corps JSON :
await request.json()
const body = await request.json()
console.log(body.email)
- FormData :
await request.formData()
const formData = await request.formData()
const file = formData.get('avatar')
- 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')
// ...
}
- 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
- Attendues : paramètres invalides, ressource absente, droits insuffisants → 4xx
- 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 :
- Méthodes HTTP : GET lire, POST créer, PUT/PATCH modifier, DELETE supprimer
- URL = ressources :
/api/users,/api/users/123,/api/users/123/posts - 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 :
- Fichier nommé
route.tsouroute.js, pasapi.ts route.tspas au même niveau quepage.tsx- 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/resversRequest/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,
redirectdans 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 :
- Réécrire un endpoint existant en Route Handlers
- Créer un template maison (erreurs + format de réponse)
- 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 ?
Pourquoi mon GET ne renvoie pas les données les plus récentes ?
Quand utiliser Route Handlers plutôt que Server Components ?
Comment gérer proprement les erreurs d'API ?
Après déploiement, toutes les API renvoient 404 alors qu'en local ça marche ?
12 min de lecture · Publié le: 5 janv. 2026 · Mis à jour le: 27 juil. 2026
Guide complet Next.js
Si vous arrivez depuis la recherche, le plus rapide est de passer à l’article précédent ou suivant de cette série.
Précédent
SEO multilingue Next.js : guide complet pour un indexage correct de chaque langue
Plus de 60 % des sites multilingues ont des erreurs de configuration SEO. Ce guide détaille hreflang, les sitemaps multilingues et le choix de la stratégie d’URL pour éviter les pièges et faire ranker chaque version linguistique.
Partie 17 sur 51
Suivant
Guide complet d'optimisation des performances API Next.js : cache, streaming et edge computing
Réponse API Next.js de 3 s à 500 ms ? Stratégies de cache, réponses en streaming et Edge Functions avec exemples de code réels et données de performance pour booster vos API dès maintenant.
Partie 19 sur 51



Commentaires
Connectez-vous avec GitHub pour laisser un commentaire