Route Handlers vs Server Actions dans Next.js 16 : utilisez les Server Actions pour les mutations déclenchées depuis votre propre UI (formulaires, boutons, optimistic updates), et les Route Handlers pour tout ce qui vient de l'extérieur : webhooks, clients mobiles, streaming SSE, uploads > 1 Mo. Les deux primitives coexistent. Ce ne sont pas des concurrents, mais bien deux outils avec des surfaces d'attaque, des sémantiques de cache et des modèles de test très différents. Honnêtement, j'ai migré une app de 400 routes API vers l'App Router l'an dernier, et voici le tableau de décision que j'aurais aimé avoir sous les yeux.
Les Server Actions sont POST-only, protégées CSRF nativement, mais plafonnées à 1 Mo de body. Parfaites pour les formulaires, mauvais choix pour les uploads volumineux.
Les Route Handlers acceptent tous les verbes HTTP, exposent une URL stable, et sont dynamiques par défaut dans Next.js 16 (même les GET).
Depuis Next.js 16, updateTag() remplace revalidateTag() pour invalider immédiatement le cache après une mutation, mais uniquement dans une Server Action.
Pour un webhook Stripe : Route Handler obligatoire (accès au raw body, vérification de signature, réponse 200 rapide).
En self-hosting, fixez NEXT_SERVER_ACTIONS_ENCRYPTION_KEY pour éviter les erreurs « Failed to find Server Action » lors des déploiements.
Les Server Actions s'appellent depuis un Client Component comme n'importe quelle fonction async. C'est un RPC déguisé, pas un endpoint.
Route Handlers ou Server Actions : la réponse courte
Après deux ans à passer du dossier pages/api vers app/, j'ai fini par considérer ces deux primitives comme des couches complémentaires. Les Server Actions sont un mécanisme RPC : une fonction TypeScript async marquée "use server" que React sérialise en requête POST vers un endpoint interne, avec protection CSRF gérée par le framework et intégration native aux hooks React 19 (useActionState, useFormStatus, useOptimistic). Les Route Handlers, eux, sont l'équivalent moderne de pages/api : un fichier route.ts qui exporte des handlers HTTP standards (GET, POST, PUT, DELETE) travaillant sur des objets Request et Response Web natifs.
La règle mentale que j'utilise depuis Next.js 15 : si le déclencheur est un utilisateur qui interagit avec ma propre UI, je pars sur une Server Action. Si le déclencheur est un système externe (Stripe, GitHub, un client iOS, un cron), je pars sur un Route Handler. Cette distinction couvre 90 % des cas. Les 10 % restants sont des uploads volumineux, du streaming SSE et du polling long, qui basculent aussi côté Route Handler pour des raisons techniques que nous détaillons plus bas.
Tableau comparatif Route Handlers vs Server Actions
Le tableau ci-dessous synthétise les dimensions qui comptent réellement en production. Je l'utilise comme grille de décision pendant les revues de code d'onboarding : quand un développeur hésite entre les deux, on regarde d'abord la ligne « déclencheur » puis la ligne « taille du body », et 8 fois sur 10, la réponse est évidente.
Critère
Route Handlers
Server Actions
Fichier / directive
app/**/route.ts
Fonction "use server"
Verbes HTTP
GET, POST, PUT, PATCH, DELETE, OPTIONS
POST uniquement
URL publique stable
Oui (/api/...)
Non (endpoint interne, ID rotatif)
Cache par défaut (Next.js 16)
Dynamique, opt-in avec 'use cache'
Sans objet (mutation)
Protection CSRF native
Non, à implémenter
Oui (check Origin vs Host)
Taille max du body
Configurable, ~4,5 Mo par défaut serverless
1 Mo (configurable via serverActions.bodySizeLimit)
Progressive enhancement
Manuel (<form action="/api/...">)
Natif, fonctionne sans JS
Streaming (SSE, LLM)
Oui via ReadableStream
Non, retour sérialisé unique
Testabilité unitaire
Facile, Request/Response Web standard
Nécessite mocker les internals Next
Consommable par mobile / tRPC / cURL
Oui
Non (ID d'action rotatif)
Hooks React 19 intégrés
Non
Oui : useActionState, useOptimistic
Quand utiliser les Server Actions plutôt qu'une API route ?
Les Server Actions gagnent chaque fois que la mutation est déclenchée par un composant React que vous contrôlez, et que la réponse doit rafraîchir immédiatement une vue serveur. C'est le cas typique : un formulaire de création de commentaire, un bouton « ajouter au panier », un toggle de statut. Le gain n'est pas syntaxique. C'est l'intégration bout en bout avec React 19 qui change la donne. Vous récupérez gratuitement le pending state via useFormStatus, la gestion du state d'erreur via useActionState, et les optimistic updates via useOptimistic, sans écrire de couche fetch/mutate à la main.
À l'inverse, je bascule sur un Route Handler dès qu'un des trois critères suivants apparaît : (1) le déclencheur est externe et exige une URL stable, (2) je dois streamer la réponse (SSE, réponse LLM token par token, download progressif), ou (3) j'ai besoin d'un verbe HTTP autre que POST pour respecter la sémantique REST, ou pour un webhook qui envoie du PUT. Vous trouverez plus de détails dans mon guide complet sur les Server Actions et la validation Zod, qui couvre la sécurisation ligne par ligne.
Route Handlers dans Next.js 16 : signature et cache par défaut
Le changement le plus important à comprendre en Next.js 16 : les Route Handlers sont dynamiques par défaut, y compris les GET. Cela reflète la nouvelle philosophie de cache explicite introduite avec 'use cache' et les Cache Components. Fini le temps où un GET était mis en cache indéfiniment sans que vous ne l'ayez demandé. C'est la source de bugs numéro un chez les équipes que j'ai vues migrer depuis Next.js 13 (j'ai passé une soirée entière à traquer un bug de ce type sur un dashboard analytics, croyez-moi).
Voici un handler GET avec cache opt-in et un handler POST avec validation Zod, prêts à coller dans votre projet :
// app/api/posts/route.ts
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { auth } from '@/lib/auth'
// GET dynamique par défaut. Opt-in cache via 'use cache' :
export async function GET(request: Request) {
'use cache'
const posts = await db.query.posts.findMany({ limit: 20 })
return NextResponse.json(posts, {
headers: { 'Cache-Control': 's-maxage=60, stale-while-revalidate=300' },
})
}
const CreatePostSchema = z.object({
title: z.string().min(3).max(120),
body: z.string().min(10),
})
export async function POST(request: Request) {
const session = await auth()
if (!session) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
const json = await request.json()
const parsed = CreatePostSchema.safeParse(json)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 422 })
}
const post = await db.insert(schema.posts).values({
...parsed.data,
authorId: session.user.id,
}).returning()
return NextResponse.json(post, { status: 201 })
}
Notez trois détails que j'ai appris à la dure : (1) toujours retourner NextResponse.json plutôt que new Response(JSON.stringify(...)), car vous héritez du bon Content-Type et de la sérialisation stable ; (2) les erreurs de validation renvoient 422, pas 400, c'est la convention HTTP correcte pour un body sémantiquement invalide ; (3) le Cache-Control côté réponse est ce que les CDN edge (Vercel, Cloudflare) écoutent, alors que la directive 'use cache' gère uniquement le cache data côté serveur Next.
Server Actions dans Next.js 16 : useActionState, useOptimistic, updateTag
La combinaison qui rend les Server Actions vraiment supérieures aux Route Handlers pour l'UI, c'est le trio useActionState + useOptimistic + updateTag. Ce dernier est nouveau en Next.js 16 et remplace revalidateTag pour les cas où vous voulez une invalidation immédiate et bloquante (read-your-writes), pas une simple mise en file d'attente stale-while-revalidate.
// app/comments/actions.ts
'use server'
import { z } from 'zod'
import { updateTag } from 'next/cache'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
const CommentSchema = z.object({
postId: z.string().uuid(),
body: z.string().trim().min(1).max(2000),
})
type ActionState = { ok: boolean; error?: string }
export async function addComment(
_prev: ActionState,
formData: FormData,
): Promise<ActionState> {
const session = await auth()
if (!session) return { ok: false, error: 'Non authentifié' }
const parsed = CommentSchema.safeParse({
postId: formData.get('postId'),
body: formData.get('body'),
})
if (!parsed.success) return { ok: false, error: 'Données invalides' }
await db.insert(schema.comments).values({
...parsed.data,
authorId: session.user.id,
})
// Invalidation immédiate : le prochain render verra le commentaire.
updateTag(`post:${parsed.data.postId}:comments`)
return { ok: true }
}
Ce pattern est impossible à reproduire proprement avec un Route Handler : il faudrait câbler à la main la mutation, le refresh du router et l'optimistic state via SWR ou React Query. L'intégration React 19, c'est vraiment le vrai argument commercial des Server Actions.
Peut-on appeler une Server Action depuis un Client Component ?
Oui, et c'est même le cas d'usage principal. Une Server Action importée dans un Client Component est traitée comme n'importe quelle fonction async : vous l'appelez avec await, vous récupérez sa valeur de retour, vous gérez ses erreurs avec try/catch. Ce qui diffère d'un appel fetch classique, c'est que React sérialise automatiquement les arguments, envoie la requête POST vers l'endpoint interne, déchiffre les closures côté serveur, exécute la fonction, et vous renvoie le résultat sérialisé, sans que vous n'ayez à écrire une URL, un handler ou un client HTTP.
Le piège classique : ne jamais importer une Server Action dans un Server Component pour la « déclencher au render ». Les Server Actions sont conçues pour être appelées depuis un événement utilisateur (submit de form, click de bouton), pas depuis le rendu. Si vous avez besoin d'exécuter du code au render côté serveur, vous êtes déjà… côté serveur. Appelez directement votre fonction data-layer.
Server Actions ou Route Handlers pour un webhook Stripe ?
Pour un webhook Stripe (ou Clerk, ou GitHub, ou Sanity), la réponse est catégorique : Route Handler obligatoire. Trois raisons techniques rendent la Server Action inutilisable ici. Premièrement, Stripe a besoin d'une URL publique et stable (/api/webhooks/stripe) pour envoyer les événements ; l'ID rotatif d'une Server Action ne convient pas. Deuxièmement, vous devez accéder au raw body pour vérifier la signature Stripe-Signature, ce que la sérialisation d'une Server Action rend impossible. Troisièmement, la protection CSRF native des Server Actions rejetterait la requête Stripe, dont l'Origin ne matche pas votre Host.
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(request: Request) {
const rawBody = await request.text() // NE PAS parser en JSON
const signature = (await headers()).get('stripe-signature')
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
rawBody, signature!, process.env.STRIPE_WEBHOOK_SECRET!,
)
} catch (err) {
return new Response('Invalid signature', { status: 400 })
}
// Répondre 200 en < 5s puis traiter en arrière-plan
queueMicrotask(() => handleEvent(event).catch(console.error))
return new Response('ok', { status: 200 })
}
updateTag vs revalidateTag : la nouvelle sémantique du cache
Next.js 16 introduit une distinction sémantique importante que la plupart des articles récents ratent encore. revalidateTag est désormais stale-while-revalidate : il marque le cache comme obsolète, mais laisse le prochain requêteur voir potentiellement l'ancienne valeur pendant que la nouvelle est calculée en arrière-plan. Sa signature attend maintenant un profil cacheLife ('max', 'hours', 'days', ou { expire: 3600 }) ; l'ancienne forme mono-argument est dépréciée.
updateTag, nouveau, est bloquant et immédiat : la mutation invalide le cache et garantit que la prochaine lecture verra la nouvelle valeur (read-your-writes). C'est ce que vous voulez pour un « ajouter un commentaire puis re-rendre la liste » côté formulaire. Point crucial : updateTag ne fonctionne que dans une Server Action. Il lève une exception dans un Route Handler, un middleware ou un Client Component. Voir la discussion GitHub officielle sur updateTag vs revalidateTag pour la justification de ce choix architectural.
Enfin, ni l'un ni l'autre ne flushent le Router Cache côté client. Si votre UI utilise du router.prefetch agressif, appelez également revalidatePath avec le chemin concerné ; sinon l'utilisateur verra la donnée fraîche après un F5, mais pas en cliquant sur un lien préchargé.
Sécuriser vos Server Actions : CSRF, chiffrement, clé de production
La sécurité des Server Actions repose sur trois mécanismes que le framework fournit, mais qui exigent une configuration correcte en production. Premièrement, la protection CSRF compare l'en-tête Origin à Host/X-Forwarded-Host. Si votre app est derrière un proxy (Cloudflare, un load balancer custom), déclarez les origines de confiance via serverActions.allowedOrigins dans next.config.js, sinon vos actions retourneront systématiquement 403. C'est directement lié aux mécanismes que je détaille dans le guide de migration middleware vers proxy.
Deuxièmement, les closures capturées par vos Server Actions (variables locales référencées dans le corps de la fonction) sont chiffrées côté serveur avec une clé par build. En self-hosting, si vous ne fixez pas NEXT_SERVER_ACTIONS_ENCRYPTION_KEY (base64, 32 bytes recommandé), chaque build génère une nouvelle clé, et vos utilisateurs actuels reçoivent des erreurs « Failed to find Server Action » pendant le rolling deploy. J'ai eu ce bug en prod sur un client, ça pique. Générez-en une une seule fois avec openssl rand -base64 32 et injectez-la comme variable d'environnement sur toutes vos instances.
Troisièmement, l'authentification et l'autorisation sont votre responsabilité. Vérifiez la session au début de chaque action, validez les entrées avec Zod, et si vous exposez des mutations sensibles (paiement, suppression irréversible), ajoutez du rate-limiting via Upstash ou Arcjet. La documentation officielle des Server Actions couvre les headers de sécurité recommandés en production. Pensez aussi à combiner cela avec un Data Access Layer bien isolé pour garder l'autorisation centralisée.
Arbre de décision : quel primitif choisir ?
Voici l'arbre que j'utilise en revue de code pour trancher rapidement. Répondez aux questions dans l'ordre, la première à laquelle vous répondez « oui » détermine le choix.
Le déclencheur est-il un système externe (webhook, cron, client mobile) ? → Route Handler.
Ai-je besoin d'une URL publique stable, ou d'un verbe autre que POST ? → Route Handler.
Est-ce que je streame la réponse (SSE, LLM token par token, download progressif) ? → Route Handler.
Est-ce que je manipule un body de plus de 1 Mo (upload de fichier direct) ? → Route Handler + URL signée.
Est-ce une mutation déclenchée par un formulaire ou un événement utilisateur dans mon UI ? → Server Action.
Ai-je besoin de useActionState, useFormStatus, useOptimistic ou updateTag ? → Server Action.
Si vous répondez « non » à toutes les questions, c'est probablement que votre besoin ne concerne ni l'un ni l'autre. Vous êtes peut-être en train de vouloir faire du data fetching, et la réponse est alors un simple async function Page() avec du 'use cache', ou l'utilisation du pattern Partial Prerendering (PPR) pour combiner statique et dynamique dans la même page. Consultez également les notes de version officielles de Next.js 16 pour la liste complète des changements affectant ces deux primitives.
Questions fréquentes
Les Server Actions remplacent-elles les Route Handlers ?
Non. L'équipe Next.js a confirmé plusieurs fois que les deux primitives sont complémentaires et destinées à cohabiter. Les Server Actions couvrent l'UI interne ; les Route Handlers couvrent les API publiques, webhooks et streaming. Chacune a ses forces et ses limites (URL stable, verbes HTTP, taille de body, cache par défaut) que le tableau comparatif de cet article résume.
Une Server Action est-elle une requête POST ?
Oui : techniquement, React sérialise l'appel en une requête POST multipart vers un endpoint interne géré par Next.js. Vous ne voyez ni la méthode ni l'URL dans votre code, mais elles existent dans les DevTools. C'est pourquoi les Server Actions ne peuvent pas être appelées avec un autre verbe HTTP.
Comment tester une Server Action en unitaire ?
C'est le point faible : il faut mocker les internals Next (next/cache, next/headers) ou extraire toute la logique métier dans une fonction pure testable séparément. Les Route Handlers, à l'inverse, se testent facilement en construisant un Request Web standard et en asserant sur le Response. Pour cette raison, je garde souvent la Server Action comme une simple couche d'orchestration qui délègue à une fonction data-layer pure.
Les Route Handlers sont-ils cachés par défaut dans Next.js 16 ?
Non, c'est le changement majeur de Next.js 16. Même les GET sont dynamiques par défaut. Pour opter dans le cache, ajoutez 'use cache' en tête du handler, ou configurez export const dynamic = 'force-static'. Ce nouveau default reflète la philosophie « cache explicite » introduite avec les Cache Components.
Peut-on utiliser updateTag dans un Route Handler ?
Non. updateTag lève une exception hors d'une Server Action ; c'est une contrainte architecturale volontaire liée à sa garantie read-your-writes. Dans un Route Handler, utilisez revalidateTag avec un profil cacheLife (sémantique stale-while-revalidate) ou revalidatePath si vous voulez également invalider le Router Cache côté client.
Turbopack devient le bundler par défaut de Next.js 16 pour next build. Guide pratique : activation, migration depuis webpack, cache persistant, patterns monorepo Turborepo et benchmarks réels sur une app SaaS de 400+ routes.
Activez le Partial Prerendering dans Next.js 16 pour combiner coque statique et trous dynamiques Suspense. Guide pratique avec exemples e-commerce et déploiement Vercel.
Chaque Server Action crée un endpoint HTTP public. Apprenez à sécuriser vos formulaires Next.js 16 avec useActionState, Zod, updateTag() et next-safe-action — avec des exemples TypeScript prêts à l'emploi.