Accueil / Articles / Actions serveur ou gestionnaires de route ? Un guide de décision pour Next.js 16

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.

1558 mots

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 revalidatePath ou des balises ; les gestionnaires définissent Cache-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

  • Pourquoi les Server Actions de Next.js nécessitent des autorisations dans chaque corps de fonction — Un cas d’attaque par prise de contrôle du compte détaillé montre comment les Server Actions non authentifiés de Next.js exposent des opérations privilégiées, et où le contrôle d’autorisation doit être placé pour y remédier.
  • Triage de la mise à jour de React 19 : quelles nouvelles API remplacent vos solutions provisoires — Une analyse pratique de use, Server Actions, useOptimistic et le React Compiler, avec les précautions importantes et un plan pour adopter en premier quels changements de React 19.
  • Où dessiner la ligne serveur-client dans une page App Router de Next.js — Un modèle mental pratique pour les composants serveur React : ce qui s’exécute où, comment une page de blog se divise en parties serveur et client, ainsi que les règles d’import à respecter.
  • Next.js ou React simple ? Comment choisir le framework pour votre projet — Une méthode basée sur les besoins pour déterminer si un nouveau projet React a besoin de Next.js, abordant le SEO, le rendu hybride, les limites d’hébergement, les besoins en routage et l’expérience de l’équipe.
  • Une source unique de vérité pour les trajectoires de routage dans les applications Next.js — Découvrez comment un module centralisé de routage typé remplace les trajectoires en chaîne dispersées dans Next.js, grâce à des fonctions d’aide pour les segments dynamiques et les exportations de routage groupées.
  • Pourquoi les WebSockets bloquent dans les routes API de Next.js et comment un server personnalisé le résout — Apprenez pourquoi un serveur ws à l’intérieur d’une route pages/api ne termine jamais la mise en relation, et comment gérer vous-même l’événement d’upgrade avec un server Next.js personnalisé.