Actions serveur ou gestionnaires de route ? Un guide de décision pour Next.js 16
Apprenez quand une mutation de Next.js 16 doit être placée dans une action serveur et quand elle nécessite un gestionnaire de route, avec des exemples corrigés pour les formulaires, les erreurs, l’optimisme et les webhooks.
Les Server Actions ont été introduits dans Next.js 13.4 comme raccourci pour les soumissions de formulaires et constituent désormais une primitive fondamentale d’App Router ; pourtant, chaque nouvelle fonctionnalité soulève la même question : action ou route API ? Voici un modèle mental pour faire ce choix, des exemples de code pour les deux approches, ainsi que des solutions aux idées reçues qui entraînent de véritables bugs.
Deux contrats différents
Les routes API, appelées Route Handlers dans App Router (app/api/.../route.ts), sont des points de terminaison HTTP ordinaires. Elles disposent d’URL stables que tout client peut appeler, d’un contrôle total sur les codes d’état et les en-têtes de mise en cache, et ne sont pas liées à vos composants.
Les Server Actions sont des fonctions 'use server' invoquées depuis votre code React. Next.js génère le point de terminaison, sérialise les arguments et les résultats, puis les intègre aux formulaires, aux transitions et à une interface utilisateur optimiste, en assurant que les types soient transmis au site d’appel.
Contrairement à une idée répandue, les actions serveur sont pas privées : chacune correspond à une extrémité POST identifiée par un ID d’action, et quiconque possède cet ID peut l’appeler avec des arguments arbitraires.
Règle générale : les appels externes nécessitent un gestionnaire de route ; les mutations déclenchées par votre propre interface utilisateur conviennent généralement à une action serveur.
Les actions serveur en pratique
Une mutation basée sur un formulaire
Le fichier commence par cette directive, qui marque chaque export comme une fonction serveur :
// app/actions/order.ts
'use server'
L’action authentifie l’utilisateur, valide les champs, enregistre la commande, révérifie à nouveau /orders puis redirige. La fonction auth() lit les cookies de la requête, de sorte que le client n’a pas besoin d’envoyer de token.
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'export async function createOrder(formData: FormData) {
// Auth is automatic — no need to pass session
const session = await auth()
if (!session?.user) throw new Error('Unauthorized') const item = formData.get('item') as string
const quantity = Number(formData.get('quantity')) // Validate
if (!item || quantity < 1) {
return { error: 'Invalid order data' }
} // Database write
const order = await db.order.create({
data: { item, quantity, userId: session.user.id },
}) // Invalidate cached data
revalidatePath('/orders') // Redirect after success
redirect(`/orders/${order.id}`)
}
Notez que la fonction redirect fonctionne en lançant une exception, il ne faut donc jamais l’appeler à l’intérieur d’un bloc try qui capture les erreurs. La page importe cette action :
// app/shop/page.tsx
import { createOrder } from '@/app/actions/order'
et le transmet directement à la propriété action d’un formulaire :
export default function ShopPage() {
return (
<form action={createOrder}>
<input name="item" type="text" placeholder="Item name" />
<input name="quantity" type="number" defaultValue={1} />
<button type="submit">Order</button>
</form>
)
}
Aucun état client, aucun effet ni utilisation de fetch, et le formulaire est soumis même avant que JavaScript ne soit chargé.
Retour des erreurs avec useActionState
useActionState (React 19) stocke la dernière valeur de retour de l’action ainsi qu’un indicateur d’état en attente :
'use client'
import { useActionState } from 'react'
import { createOrder } from '@/app/actions/order'
export function OrderForm() {
const [state, action, isPending] = useActionState(createOrder, null) return (
<form action={action}>
{state?.error && (
<p style={{ color: 'red' }}>{state.error}</p>
)}
<input name="item" />
<input name="quantity" type="number" />
<button type="submit" disabled={isPending}>
{isPending ? 'Ordering...' : 'Place Order'}
</button>
</form>
)
}
Un problème : lorsque une action est enveloppée par useActionState, elle reçoit l’état précédent en tant que premier argument et FormData en deuxième. La signature de createOrder ci-dessus ne prend que formData ; par conséquent, pour ce formulaire, elle doit devenir createOrder(prevState, formData).
Rétroaction optimiste avec useOptimistic
useOptimistic affiche le résultat attendu jusqu’à ce que l’action soit finalisée :
'use client'
import { useOptimistic } from 'react'
import { toggleLike } from '@/app/actions/post'
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [optimisticLikes, setOptimistic] = useOptimistic(initialLikes) async function handleLike() {
setOptimistic(prev => prev + 1) // UI updates instantly
await toggleLike(postId) // Server call happens in background
} return (
<button onClick={handleLike}>
❤️ {optimisticLikes}
</button>
)
}
Le setteur optimiste doit être exécuté à l’intérieur d’une transition ou d’une action. À partir d’un simple onClick, enveloppez le corps dans startTransition ; sinon React émet une alerte et l’état optimiste ne se comporte pas comme prévu. Les pièges liés aux annulations sont abordés dans les cinq modes d’échec de useOptimistic.
Lorsqu’un Route Handler est l’outil idéal
Cet endpoint versionné met en avant ses forces : paramètres de requête, mise en cache via CDN et authentification par clé API.
// app/api/v1/products/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const category = searchParams.get('category') const products = await db.product.findMany({
where: category ? { category } : undefined,
}) return NextResponse.json({ products }, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
}
})
}export async function POST(request: NextRequest) {
const apiKey = request.headers.get('x-api-key')
if (apiKey !== process.env.API_KEY) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} const body = await request.json()
const product = await db.product.create({ data: body })
return NextResponse.json({ product }, { status: 201 })
}
Choisissez un Route Handler lorsque :
- des applications mobiles consomment l’endpoint ;
- un tiers fournit des webhooks ;
- les réponses nécessitent leurs propres en-têtes de mise en cache ;
- il s’agit d’une API ou d’un produit API public et versionné.
Dans un environnement de production, validez le corps du POST plutôt que de le transmettre à la base de données sans vérification.
Comparaison des deux approches
- Allers-retours : dans les deux cas, une requête est envoyée depuis le navigateur vers votre serveur ; aucune n’est entre serveurs.
- Bundle du client : la logique reste toujours sur le serveur, quel que soit le cas.
- Authentification : les actions lisent automatiquement la session depuis les cookies ; les gestionnaires qui servent d’autres clients vérifient les tokens ou clés à chaque requête.
- Types : partagés de bout en bout avec les actions ; les gestionnaires nécessitent un client ou un schéma typé.
- Cachage : les actions invalident le cache avec
revalidatePathou des balises ; les gestionnaires définissentCache-Control. - UI optimiste : intégré aux hooks de React pour les actions ; manuel pour les gestionnaires.
Au moment de la rédaction de ce texte, Next.js envoie également des Server Actions depuis le client un par un, ce qui les rend adaptés aux mutations plutôt qu’aux lectures de données en parallèle ; consultez la documentation actuelle.
Checklist de décision
Commencez par la question qui permet de trancher dans la plupart des cas :
Does an external system call this?
YES → API Route
Puis abordez le reste :
Is this triggered by a form submit or user action within your UI?
YES → Server ActionDo you need explicit HTTP caching headers?
YES → API RouteDo you want automatic auth context without passing tokens?
YES → Server ActionDo you need to call this from a mobile app?
YES → API RouteEverything else?
→ Server Action (less boilerplate)
Deux erreurs à éviter
Diriger les webhooks vers une Server Action
Stripe envoie des données vers une URL enregistrée et ne peut pas cibler un ID d’action.
// ❌ Wrong — Server Actions can't receive arbitrary HTTP POSTs from Stripe
'use server'
export async function handleStripeWebhook() { ... }
Les webhooks doivent être gérés dans un Route Handler, où vous pouvez lire le corps brut et vérifier la signature.
// ✅ Correct
// app/api/webhooks/stripe/route.ts
export async function POST(request: NextRequest) { ... }
Faire confiance aux entrées des actions
Puisque n’importe quelle action peut être invoquée directement, une action de suppression sans vérifications permet à n’importe qui de supprimer n’importe quoi :
// ❌ Wrong — Server Actions are not inherently trusted
'use server'
export async function deletePost(postId: string) {
await db.post.delete({ where: { id: postId } }) // Anyone can call this!
}
La solution consiste à vérifier la session et le droit d’accès avant d’écrire, puis à révalider ensuite :
// ✅ Correct — always validate auth + ownership
'use server'
export async function deletePost(postId: string) {
const session = await auth()
const post = await db.post.findUnique({ where: { id: postId } })
if (post?.userId !== session?.user?.id) throw new Error('Forbidden')
await db.post.delete({ where: { id: postId } })
revalidatePath('/posts')
}
Rejetez également explicitement les sessions manquantes. Consultez notre article sur l’autorisation à l’intérieur de chaque action serveur qui aborde ce sujet en profondeur.
Points clés
- Chaque action serveur est une extrémité accessible ; authentifiez, autorisez et validez à l’intérieur d’elle.
- Avec
useActionState, l’action reçoit d’abord l’état précédent. - Exécutez les mises à jour
useOptimisticà l’intérieur d’une transition.
Lectures complémentaires
- useOptimistic Rollback : Cinq modes de faillance dans les actions serveur de Next.js — Découvrez pourquoi useOptimistic rétablit silencieusement l’interface utilisateur sans expliquer les pannes aux utilisateurs, à travers cinq modes de faillance testés des actions serveur ainsi qu’une solution fonctionnelle.