Server Actions dans Next.js 16 : Formulaires, Validation Zod et Sécurité

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.

Les Server Actions sont probablement la fonctionnalité la plus transformatrice de Next.js ces dernières années. Avec Next.js 16, elles atteignent enfin une vraie maturité : stables, performantes, et enrichies de nouvelles APIs comme updateTag() et refresh(). Mais honnêtement, beaucoup de développeurs les utilisent encore mal — ou pire, les exposent sans aucune protection.

Parce que oui, chaque fonction marquée 'use server' crée un endpoint HTTP POST public. Sans validation. Sans authentification. Sans autorisation. Si vous ne sécurisez pas vos Server Actions, n'importe qui peut les appeler avec n'importe quelles données. Et ça, c'est un vrai problème.

Dans ce guide, on va construire ensemble un système complet de gestion de formulaires avec Server Actions, useActionState (React 19), validation Zod côté client et serveur, et toutes les bonnes pratiques de sécurité. Tous les exemples sont testés avec Next.js 16.1 et React 19, en mars 2026.

Comprendre les Server Actions dans Next.js 16

Avant de plonger dans le code, prenons un moment pour bien comprendre ce que sont les Server Actions et comment elles fonctionnent sous le capot. C'est indispensable si vous voulez écrire du code sécurisé — et croyez-moi, on verra plus loin que beaucoup de devs sautent cette étape.

Le mécanisme interne

Une Server Action, c'est une fonction asynchrone qui s'exécute exclusivement sur le serveur. Quand vous marquez une fonction avec la directive 'use server', Next.js génère automatiquement un endpoint HTTP POST avec un identifiant chiffré. Côté client, l'appel à cette fonction déclenche une requête réseau transparente — aucun boilerplate API à écrire de votre côté.

// actions/post-actions.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  // Insertion en base de données
  await db.posts.create({ title, content })

  // Rafraîchir la page pour afficher le nouveau post
  revalidatePath('/blog')
}

Quelques points importants à retenir :

  • POST uniquement — Les Server Actions utilisent exclusivement la méthode POST. Elles ne peuvent pas être mises en cache et ne sont pas faites pour la récupération de données.
  • URL chiffrée — Contrairement aux Route Handlers (/api/users), l'URL d'une Server Action est auto-générée et non prédictible. Mais attention : ça ne veut pas dire qu'elle est secrète.
  • Sérialisation automatique — Les arguments et valeurs de retour sont sérialisés par React. Vous pouvez passer des types primitifs, des objets, des tableaux, des Dates, des FormData, et même des éléments JSX.
  • Progressive enhancement — Un formulaire qui utilise une Server Action via l'attribut action fonctionne même si JavaScript est désactivé côté client. C'est un point souvent sous-estimé.

Ce qui change dans Next.js 16

Next.js 16 apporte deux nouvelles APIs exclusives aux Server Actions qui changent vraiment la façon dont on gère le cache après une mutation :

  • updateTag() — Fournit une sémantique read-your-writes : le tag de cache est expiré et les données sont rafraîchies immédiatement dans la même requête. L'utilisateur voit instantanément le résultat de sa modification.
  • refresh() — Rafraîchit uniquement les données non mises en cache affichées sur la page. Les shells de pages et contenus statiques restent intacts.
'use server'

import { updateTag, refresh } from 'next/cache'

export async function updateProfile(formData: FormData) {
  const userId = formData.get('userId') as string
  const name = formData.get('name') as string

  await db.users.update(userId, { name })

  // Le profil mis à jour est affiché immédiatement
  updateTag(`user-${userId}`)
}

export async function markNotificationRead(id: string) {
  await db.notifications.markRead(id)

  // Rafraîchit le compteur de notifications sans toucher au cache
  refresh()
}

La différence avec revalidateTag() est subtile mais cruciale. En gros, revalidateTag() marque le cache comme périmé et c'est la requête suivante qui déclenche la revalidation. updateTag(), lui, expire et rafraîchit dans la même requête — zéro délai pour l'utilisateur. En pratique, c'est nuit et jour côté UX.

Gérer les formulaires avec useActionState

React 19 a introduit le hook useActionState pour remplacer l'ancien useFormState de react-dom. C'est désormais le pattern recommandé pour gérer les soumissions de formulaires avec gestion d'erreurs et indicateurs de chargement intégrés.

Le pattern de base

Commençons par un formulaire de contact simple. D'abord, la Server Action :

// actions/contact-actions.ts
'use server'

import { z } from 'zod'

const ContactSchema = z.object({
  name: z.string().min(2, 'Le nom doit contenir au moins 2 caractères'),
  email: z.string().email('Adresse email invalide'),
  message: z.string().min(10, 'Le message doit contenir au moins 10 caractères'),
})

export type ContactState = {
  errors?: {
    name?: string[]
    email?: string[]
    message?: string[]
  }
  message?: string
  success?: boolean
}

export async function submitContact(
  prevState: ContactState,
  formData: FormData
): Promise<ContactState> {
  const validatedFields = ContactSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  })

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Veuillez corriger les erreurs ci-dessous.',
    }
  }

  try {
    await db.contacts.create(validatedFields.data)
    return { success: true, message: 'Message envoyé avec succès !' }
  } catch (error) {
    return { message: 'Une erreur est survenue. Veuillez réessayer.' }
  }
}

Notez la signature de la fonction : le premier argument est prevState (l'état précédent renvoyé par l'action), le second est le FormData. C'est une contrainte imposée par useActionState — le hook injecte automatiquement l'état précédent comme premier argument. Ça peut surprendre la première fois, mais on s'y fait vite.

Maintenant, le composant client :

// components/ContactForm.tsx
'use client'

import { useActionState } from 'react'
import { submitContact, type ContactState } from '@/actions/contact-actions'

const initialState: ContactState = {}

export function ContactForm() {
  const [state, formAction, pending] = useActionState(
    submitContact,
    initialState
  )

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="name">Nom</label>
        <input
          id="name"
          name="name"
          type="text"
          required
          aria-describedby="name-error"
        />
        {state.errors?.name && (
          <p id="name-error" role="alert">
            {state.errors.name[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          required
          aria-describedby="email-error"
        />
        {state.errors?.email && (
          <p id="email-error" role="alert">
            {state.errors.email[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="message">Message</label>
        <textarea
          id="message"
          name="message"
          required
          rows={5}
          aria-describedby="message-error"
        />
        {state.errors?.message && (
          <p id="message-error" role="alert">
            {state.errors.message[0]}
          </p>
        )}
      </div>

      {state.message && (
        <p role="status">{state.message}</p>
      )}

      <button type="submit" disabled={pending}>
        {pending ? 'Envoi en cours...' : 'Envoyer'}
      </button>
    </form>
  )
}

Le hook useActionState retourne trois valeurs : l'état actuel (state), la fonction à passer au formulaire (formAction), et un booléen pending qui indique si l'action est en cours. C'est tout ce qu'il faut pour un formulaire complet avec gestion d'erreurs et indicateur de chargement.

useActionState vs useFormStatus : quelle différence ?

Cette question revient souvent, alors mettons les choses au clair.

HookPackageUsage
useActionStatereactGère l'état du formulaire, le résultat de l'action et le statut pending
useFormStatusreact-domFournit uniquement le statut pending dans un composant enfant du formulaire

En pratique, useActionState couvre la quasi-totalité des cas. N'utilisez useFormStatus que quand vous avez un composant bouton réutilisable qui doit connaître l'état de soumission sans accéder à l'état complet du formulaire — c'est assez rare, mais ça arrive.

Validation robuste avec Zod : client et serveur

La validation est le premier rempart de sécurité de vos Server Actions. Et la règle d'or est simple : validez toujours côté serveur. La validation côté client, c'est du confort utilisateur — pas de la sécurité.

J'ai vu trop de projets en production où la seule validation se faisait côté client. Autant dire qu'il n'y avait pas de validation du tout.

Schéma partagé entre client et serveur

L'approche que je recommande, c'est de définir vos schémas Zod dans un fichier séparé, importable depuis le client et le serveur. Ça élimine toute duplication :

// lib/schemas/post-schema.ts
import { z } from 'zod'

export const CreatePostSchema = z.object({
  title: z
    .string()
    .min(3, 'Le titre doit contenir au moins 3 caractères')
    .max(100, 'Le titre ne peut pas dépasser 100 caractères'),
  content: z
    .string()
    .min(50, 'Le contenu doit contenir au moins 50 caractères'),
  slug: z
    .string()
    .regex(/^[a-z0-9-]+$/, 'Le slug ne doit contenir que des lettres minuscules, chiffres et tirets')
    .min(3)
    .max(80),
  categoryId: z
    .string()
    .uuid('Identifiant de catégorie invalide'),
  published: z
    .boolean()
    .default(false),
})

export type CreatePostInput = z.infer<typeof CreatePostSchema>

Validation côté serveur dans la Server Action

// actions/post-actions.ts
'use server'

import { CreatePostSchema } from '@/lib/schemas/post-schema'
import { auth } from '@/lib/auth'
import { updateTag } from 'next/cache'

export type PostActionState = {
  errors?: Record<string, string[]>
  message?: string
  success?: boolean
}

export async function createPost(
  prevState: PostActionState,
  formData: FormData
): Promise<PostActionState> {
  // 1. Authentification
  const session = await auth()
  if (!session?.user) {
    return { message: 'Vous devez être connecté pour créer un article.' }
  }

  // 2. Validation des entrées avec Zod
  const validatedFields = CreatePostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
    slug: formData.get('slug'),
    categoryId: formData.get('categoryId'),
    published: formData.get('published') === 'on',
  })

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Veuillez corriger les erreurs du formulaire.',
    }
  }

  // 3. Autorisation (l'utilisateur a-t-il le droit de créer un article ?)
  if (session.user.role !== 'admin' && session.user.role !== 'editor') {
    return { message: 'Vous n\'avez pas la permission de créer des articles.' }
  }

  // 4. Mutation
  try {
    await db.posts.create({
      ...validatedFields.data,
      authorId: session.user.id,
    })

    updateTag('posts')
    return { success: true, message: 'Article créé avec succès !' }
  } catch (error) {
    return { message: 'Une erreur est survenue lors de la création.' }
  }
}

Remarquez l'ordre des opérations : authentification → validation → autorisation → mutation. C'est un pattern fondamental. Gravez-le dans votre mémoire et appliquez-le systématiquement à chaque Server Action, sans exception.

Validation côté client pour l'UX

Pour améliorer l'expérience utilisateur, on peut ajouter une couche de validation côté client qui utilise le même schéma Zod. L'idée, c'est de fournir un retour instantané sans attendre la réponse du serveur :

// components/CreatePostForm.tsx
'use client'

import { useActionState, useState } from 'react'
import { CreatePostSchema, type CreatePostInput } from '@/lib/schemas/post-schema'
import { createPost, type PostActionState } from '@/actions/post-actions'

export function CreatePostForm() {
  const [state, formAction, pending] = useActionState(createPost, {})
  const [clientErrors, setClientErrors] = useState<Record<string, string[]>>({})

  function handleBlur(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
    const { name, value } = e.target
    const result = CreatePostSchema.shape[name as keyof CreatePostInput]
      ?.safeParse(value)

    if (result && !result.success) {
      setClientErrors(prev => ({
        ...prev,
        [name]: result.error.flatten().formErrors,
      }))
    } else {
      setClientErrors(prev => {
        const next = { ...prev }
        delete next[name]
        return next
      })
    }
  }

  // Les erreurs serveur prennent priorité sur les erreurs client
  const errors = state.errors ?? clientErrors

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="title">Titre</label>
        <input
          id="title"
          name="title"
          onBlur={handleBlur}
          aria-describedby="title-error"
        />
        {errors?.title && (
          <p id="title-error" role="alert">{errors.title[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="content">Contenu</label>
        <textarea
          id="content"
          name="content"
          rows={10}
          onBlur={handleBlur}
          aria-describedby="content-error"
        />
        {errors?.content && (
          <p id="content-error" role="alert">{errors.content[0]}</p>
        )}
      </div>

      <button type="submit" disabled={pending}>
        {pending ? 'Création en cours...' : 'Créer l\'article'}
      </button>
    </form>
  )
}

Les 5 failles de sécurité à corriger dans vos Server Actions

Bon, maintenant qu'on sait construire des formulaires solides, parlons sécurité. C'est la partie que tout le monde a tendance à négliger, et pourtant c'est probablement la plus importante. Voici les cinq erreurs les plus courantes — et comment les éviter.

Faille n°1 : faire confiance aux types TypeScript à l'exécution

C'est la plus fréquente et la plus dangereuse. Les types TypeScript disparaissent complètement à la compilation — ils n'existent tout simplement plus au runtime. Une annotation userId: string n'empêche personne d'envoyer {"userId": {"$ne": null}} via cURL.

// ❌ DANGEREUX — aucune validation à l'exécution
export async function deletePost(postId: string) {
  await db.posts.delete(postId) // postId pourrait être n'importe quoi
}

// ✅ SÉCURISÉ — validation Zod obligatoire
export async function deletePost(postId: unknown) {
  const validated = z.string().uuid().safeParse(postId)
  if (!validated.success) {
    throw new Error('Identifiant invalide')
  }
  await db.posts.delete(validated.data)
}

Faille n°2 : vérifier l'auth dans la page mais pas dans l'action

Le fait qu'une page soit protégée ne protège absolument pas ses Server Actions. Un attaquant peut appeler directement l'endpoint POST sans jamais visiter la page. C'est un point que beaucoup de développeurs oublient (ou ne réalisent pas).

// ❌ L'action suppose que l'utilisateur est connecté parce que la page l'est
export async function updateProfile(formData: FormData) {
  // Pas de vérification d'auth — vulnérable !
  await db.users.update(formData.get('userId'), { ... })
}

// ✅ Chaque action vérifie l'auth elle-même
export async function updateProfile(formData: FormData) {
  const session = await auth()
  if (!session?.user) {
    throw new Error('Non authentifié')
  }
  // Utiliser session.user.id, JAMAIS un ID venant du formulaire
  await db.users.update(session.user.id, { ... })
}

Faille n°3 : utiliser des IDs fournis par l'utilisateur sans vérification

Même si l'utilisateur est authentifié, il ne doit pas pouvoir modifier les données d'un autre utilisateur. C'est le principe de l'autorisation — et c'est distinct de l'authentification.

// ❌ L'utilisateur peut modifier n'importe quel post
export async function editPost(postId: string, formData: FormData) {
  await db.posts.update(postId, { title: formData.get('title') })
}

// ✅ Vérification de propriété
export async function editPost(postId: string, formData: FormData) {
  const session = await auth()
  if (!session?.user) throw new Error('Non authentifié')

  const post = await db.posts.findById(postId)
  if (post.authorId !== session.user.id && session.user.role !== 'admin') {
    throw new Error('Non autorisé')
  }

  await db.posts.update(postId, { title: formData.get('title') })
}

Faille n°4 : exposer des secrets via les closures

Celle-ci est vicieuse. Quand une Server Action capture des variables de son scope parent dans un Server Component, ces valeurs sont sérialisées et envoyées au client via le réseau. Oui, vous avez bien lu.

// ❌ Le token API est capturé par la closure et envoyé au client
export default async function Dashboard() {
  const apiToken = process.env.INTERNAL_API_TOKEN

  async function fetchData() {
    'use server'
    // apiToken est sérialisé dans le payload côté client !
    const res = await fetch('https://api.internal.com', {
      headers: { Authorization: `Bearer ${apiToken}` },
    })
    return res.json()
  }

  return <button onClick={fetchData}>Charger</button>
}

// ✅ Lire les secrets à l'intérieur de l'action
async function fetchData() {
  'use server'
  const apiToken = process.env.INTERNAL_API_TOKEN
  const res = await fetch('https://api.internal.com', {
    headers: { Authorization: `Bearer ${apiToken}` },
  })
  return res.json()
}

Faille n°5 : retourner des messages d'erreur internes

Les messages d'erreur détaillés révèlent l'architecture interne de votre application — noms de tables, structure de la base de données, bibliothèques utilisées. C'est une mine d'or pour un attaquant.

// ❌ Fuite d'informations internes
export async function createUser(formData: FormData) {
  try {
    await db.users.create({ ... })
  } catch (error) {
    return { message: error.message }
    // "duplicate key value violates unique constraint users_email_key"
  }
}

// ✅ Messages génériques pour le client
export async function createUser(formData: FormData) {
  try {
    await db.users.create({ ... })
  } catch (error) {
    console.error('Erreur création utilisateur:', error)
    return { message: 'Impossible de créer le compte. Veuillez réessayer.' }
  }
}

Aller plus loin avec next-safe-action

Si vous gérez un projet avec beaucoup de Server Actions, la bibliothèque next-safe-action peut vous faire gagner un temps fou. Elle fournit un système de middleware composable avec typage complet et validation automatique. Personnellement, je l'utilise sur tous mes projets Next.js depuis quelques mois et je ne reviendrais pas en arrière.

Configuration du client d'actions

// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action'
import { auth } from '@/lib/auth'

// Client pour les actions publiques
export const publicAction = createSafeActionClient()

// Client pour les actions authentifiées
export const authAction = createSafeActionClient({
  async middleware() {
    const session = await auth()
    if (!session?.user) {
      throw new Error('Non authentifié')
    }
    return { user: session.user }
  },
})

Création d'une action typée et sécurisée

// actions/post-actions.ts
'use server'

import { authAction } from '@/lib/safe-action'
import { CreatePostSchema } from '@/lib/schemas/post-schema'
import { updateTag } from 'next/cache'

export const createPost = authAction
  .schema(CreatePostSchema)
  .action(async ({ parsedInput, ctx }) => {
    // ctx.user est garanti par le middleware
    // parsedInput est validé et typé par Zod

    const post = await db.posts.create({
      ...parsedInput,
      authorId: ctx.user.id,
    })

    updateTag('posts')
    return { post }
  })

Avec ce pattern, chaque action bénéficie automatiquement de la vérification d'authentification, de la validation des entrées et du typage complet. Le middleware s'exécute avant chaque action — impossible d'oublier une vérification. C'est exactement le genre d'abstraction qui vaut le coup.

Server Actions vs Route Handlers : quand utiliser quoi

Cette question revient tout le temps, et la réponse est plus nuancée qu'on ne le pense. Voici un guide de décision qui devrait clarifier les choses :

CritèreServer ActionsRoute Handlers
Cas d'usage principalMutations appelées depuis vos composants ReactAPIs consommées par des clients externes
Méthodes HTTPPOST uniquementGET, POST, PUT, DELETE, PATCH
Mise en cacheJamais (POST)Oui (GET)
Type-safetyNative de bout en boutManuelle
Progressive enhancementOuiNon
Accès externeNonOui
WebhooksNonOui

Utilisez les Server Actions pour : les soumissions de formulaires, les mutations CRUD internes, les mises à jour de profil, les toggles, les actions d'administration — en gros, tout ce qui est déclenché depuis un composant React dans votre app.

Utilisez les Route Handlers pour : les webhooks (Stripe, GitHub), les APIs consommées par des applis mobiles ou des services externes, les endpoints en lecture avec mise en cache HTTP, et les intégrations tierces.

Et si vous avez besoin des deux — par exemple une mutation accessible depuis votre UI React et depuis une API publique — la solution est simple : déplacez la logique dans votre Data Access Layer et appelez la même fonction depuis la Server Action et le Route Handler.

Bonnes pratiques récapitulatives

Allez, pour conclure, voici une checklist des bonnes pratiques à garder sous le coude :

  1. Fichiers dédiés — Regroupez vos Server Actions dans des fichiers 'use server' dédiés plutôt que de les définir inline dans les composants. C'est plus maintenable et beaucoup plus facile à auditer.
  2. Jamais pour le data fetching — Les Server Actions sont faites pour les mutations. Pour récupérer des données, utilisez les Server Components ou les Route Handlers. Point.
  3. Auth + validation + autorisation — Chaque action doit vérifier l'authentification, valider les entrées avec Zod, et vérifier les autorisations. Sans exception.
  4. updateTag() après les mutations — Utilisez updateTag() pour garantir que l'utilisateur voit immédiatement le résultat de sa modification. Réservez revalidateTag() aux invalidations en arrière-plan.
  5. Limiter la taille du body — Configurez serverActions.bodySizeLimit dans votre next.config.ts pour prévenir les abus.
  6. Erreurs génériques — Ne retournez jamais de messages d'erreur internes au client. Loggez les détails côté serveur, renvoyez un message générique côté client.
  7. Rate limiting — Implémentez une limitation de débit dans votre proxy.ts ou dans un middleware de votre Data Access Layer.
// next.config.ts — Configuration recommandée
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  serverActions: {
    bodySizeLimit: '2mb',
    allowedOrigins: ['mon-domaine.fr'],
  },
}

export default nextConfig

FAQ

Les Server Actions peuvent-elles remplacer complètement les API Routes ?

Non, pas complètement. Les Server Actions sont parfaites pour les mutations internes déclenchées depuis vos composants React, mais elles ne supportent que le POST, ne peuvent pas être mises en cache, et ne sont pas accessibles depuis des clients externes (applis mobiles, webhooks, services tiers). Pour ces cas-là, les Route Handlers restent indispensables. L'approche recommandée, c'est d'utiliser les deux en parallèle, avec une logique métier centralisée dans un Data Access Layer.

Est-ce que useActionState remplace useFormState ?

Oui, c'est officiel. Avec React 19, useActionState (importé depuis react) remplace useFormState (qui était dans react-dom). L'API est similaire mais useActionState expose en plus un booléen pending directement, ce qui simplifie pas mal la gestion des indicateurs de chargement. Si vous migrez un projet existant, le changement est assez mécanique — comptez quelques minutes par formulaire.

Quelle est la différence entre updateTag() et revalidateTag() ?

revalidateTag() marque un tag de cache comme périmé — la prochaine requête qui utilise ce tag déclenche une revalidation. updateTag(), introduit dans Next.js 16, va plus loin : il expire le cache et rafraîchit les données immédiatement dans la même requête. Utilisez updateTag() dans vos Server Actions après une mutation pour un feedback instantané. Réservez revalidateTag() aux revalidations en arrière-plan de contenu semi-statique.

Comment protéger les Server Actions contre les attaques CSRF ?

Bonne nouvelle : Next.js fournit une protection CSRF intégrée. Les Server Actions n'acceptent que les requêtes POST et vérifient automatiquement l'en-tête Origin pour s'assurer que la requête provient du même domaine. Vous pouvez ajouter des domaines supplémentaires via serverActions.allowedOrigins dans votre next.config.ts. Avec les cookies SameSite activés par défaut dans les navigateurs modernes, cette protection est généralement suffisante.

Faut-il valider côté client si on valide déjà côté serveur ?

La validation côté serveur est obligatoire — c'est votre couche de sécurité, un point c'est tout. La validation côté client est optionnelle mais franchement recommandée pour l'UX. Elle fournit un retour instantané sans aller-retour réseau, et vos utilisateurs vous en remercieront. L'idéal : partagez le même schéma Zod entre client et serveur, validez champ par champ au blur côté client, et revalidez tout à la soumission côté serveur.

À propos de l'auteur Editorial Team

Our team of expert writers and editors.