Changer le thème

Tutoriel Next.js Server Actions : bonnes pratiques pour formulaires et validation

Easton editorial illustration: API gateway workstation

Devant l’écran, vous parcourez le code d’un formulaire d’inscription. Quatre fichiers empilés : composant formulaire, API Route, types, gestion d’erreurs… Pour une simple soumission, vous approchez les 200 lignes.

Existe-t-il une voie plus simple ?

Oui : les Server Actions. Cette fonctionnalité de l’App Router Next.js peut simplifier le flux de formulaires d’environ 80 %. Pas d’API Route, pas de fetch manuel, pas de gestion d’état laborieuse. Trop beau ? Vous vous demandez peut-être : est-ce vraiment sûr ? Comment valider ? Comment gérer le chargement ?

Au début, j’avais les mêmes doutes. Après quelques mois d’usage et quelques écueils, voici ce que j’ai retenu sur les Server Actions pour les formulaires — de la soumission de base à Zod, la sécurité et l’UX, avec des exemples concrets pour démarrer vite.

Bases des Server Actions

Qu’est-ce qu’une Server Action ?

Une Server Action est une fonction asynchrone côté serveur, marquée avec 'use server', utilisable directement dans l’attribut action d’un formulaire. À la soumission, elle est appelée automatiquement ; traitement, base de données, cache… tout se fait côté serveur.

Caractéristiques clés :

  • Typage fort : TypeScript couvre toute la chaîne
  • Zéro configuration : pas de dossier /api à créer
  • Traitement automatique : FormData transmis automatiquement

Deux styles : inline dans le composant ou fichier dédié (recommandé) :

// Style 1 : inline dans le composant
export default function Page() {
  async function createUser(formData: FormData) {
    'use server' // marqueur Server Action
    const name = formData.get('name')
    // traitement...
  }

  return <form action={createUser}>...</form>
}

// Style 2 : fichier séparé (recommandé)
// app/actions.ts
'use server' // marqueur au niveau fichier

export async function createUser(formData: FormData) {
  const name = formData.get('name')
  // traitement...
}

Server Actions vs API Routes traditionnelles ? Quand choisir ?

CaractéristiqueServer ActionsAPI Routes
UsageSoumission formulaire, mutationsAPI REST, appels externes
Méthodes HTTPPOST uniquementGET/POST/PUT/DELETE, etc.
TypageNatifTypes manuels
AppelAppel direct de fonctionRequête fetch
Cas d’usageLogique interne, formulairesAPI publique, intégrations
Volume de codeFaiblePlus élevé

En bref : Server Actions en interne, API Routes vers l’extérieur. Pour les formulaires de votre app, les Server Actions suffisent. Pour des interfaces tierces ou des GET, restez sur les API Routes.

Selon l’enquête Vercel 2025, 63 % des développeurs utilisent déjà les Server Actions en production. Ce n’est plus une fonctionnalité expérimentale.

"63 % des développeurs utilisent déjà les Server Actions en production"

Premier exemple de Server Action

Un formulaire de connexion minimal :

// app/login/page.tsx
export default function LoginPage() {
  async function handleLogin(formData: FormData) {
    'use server' // fonction serveur

    const email = formData.get('email') as string
    const password = formData.get('password') as string

    console.log('Tentative de connexion:', email)
    // en prod : vérification utilisateur, token, etc.
  }

  return (
    <form action={handleLogin}>
      <input
        type="email"
        name="email"
        placeholder="E-mail"
        required
      />
      <input
        type="password"
        name="password"
        placeholder="Mot de passe"
        required
      />
      <button type="submit">Connexion</button>
    </form>
  )
}

C’est tout. Points clés :

  1. 'use server' : indique à Next.js d’exécuter la fonction côté serveur
  2. formData.get() : récupère la valeur via l’attribut name
  3. action={handleLogin} : appel automatique à la soumission

Résultat : clic sur Envoyer, pas de rechargement complet, données traitées côté serveur. Moins de fetch, useState, gestion d’erreurs…

Mais c’est le minimum. En production : validation, erreurs, chargement. Suite ci-dessous.

Validation de formulaires en pratique

Validation avec Zod

Se fier au required côté client ? Insuffisant. Les outils de développement du navigateur contournent facilement ces contrôles. La validation serveur est obligatoire.

Zod valide le format côté serveur et renvoie des erreurs structurées avant toute écriture en base.

Installation :

npm install zod

Schéma et action :

// app/actions.ts
'use server'

import { z } from 'zod'

const SignupSchema = z.object({
  name: z.string().min(2, 'Le nom doit contenir au moins 2 caractères'),
  email: z.string().email('Format e-mail invalide'),
  password: z.string().min(8, 'Mot de passe : minimum 8 caractères'),
})

export async function signup(formData: FormData) {
  const rawData = {
    name: formData.get('name'),
    email: formData.get('email'),
    password: formData.get('password'),
  }

  const result = SignupSchema.safeParse(rawData)

  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors,
    }
  }

  const { name, email, password } = result.data

  console.log('Création utilisateur:', { name, email })

  return {
    success: true,
    message: 'Inscription réussie !',
  }
}

Points clés :

  1. safeParse sans exception : retourne { success: false, error: ... } en cas d’échec
  2. flatten().fieldErrors : format { name: ['erreur1'], email: ['erreur2'] } pour l’affichage
  3. Données structurées : success + erreurs pour guider le client

Comment afficher ces erreurs dans le formulaire ? Avec useActionState.

Afficher les erreurs : useActionState

useActionState (React 19, anciennement useFormState) gère l’état renvoyé par une Server Action. Il :

  • stocke la réponse serveur dans l’état du composant ;
  • fournit une action encapsulée ;
  • indique si le formulaire est en cours de soumission.
// app/signup/page.tsx
'use client'

import { useActionState } from 'react'
import { signup } from '@/app/actions'

export default function SignupPage() {
  const initialState = { success: false, errors: {}, message: '' }

  const [state, formAction, isPending] = useActionState(signup, initialState)

  return (
    <form action={formAction}>
      <div>
        <label>Nom</label>
        <input
          type="text"
          name="name"
          required
        />
        {state.errors?.name && (
          <p className="error">{state.errors.name[0]}</p>
        )}
      </div>

      <div>
        <label>E-mail</label>
        <input
          type="email"
          name="email"
          required
        />
        {state.errors?.email && (
          <p className="error">{state.errors.email[0]}</p>
        )}
      </div>

      <div>
        <label>Mot de passe</label>
        <input
          type="password"
          name="password"
          required
        />
        {state.errors?.password && (
          <p className="error">{state.errors.password[0]}</p>
        )}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? 'Envoi...' : "S'inscrire"}
      </button>

      {state.success && (
        <p className="success">{state.message}</p>
      )}
    </form>
  )
}

Flux :

  1. Soumission → appel de signup
  2. Échec validation → { success: false, errors: {...} }
  3. useActionState met à jour state
  4. Re-render avec les erreurs

isPending vaut true pendant la soumission — utile pour désactiver le bouton et afficher un libellé de chargement.

Note : après échec, les valeurs saisies peuvent disparaître. Vous pouvez renvoyer un champ values et utiliser defaultValue. L’essentiel : useActionState relie composant client et Server Action et simplifie la gestion d’état.

Optimisation de l’expérience utilisateur

État de chargement et anti double-soumission

isPending suffit souvent, mais il existe aussi useFormStatus. Facile à confondre au début.

En résumé :

  • isPending de useActionState : dans le composant formulaire
  • pending de useFormStatus : dans un sous-composant (ex. bouton)

useFormStatus doit être appelé dans un enfant de <form>, pas dans le composant formulaire lui-même. Utile pour extraire un bouton réutilisable.

// components/SubmitButton.tsx
'use client'

import { useFormStatus } from 'react-dom'

export function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus()

  return (
    <button
      type="submit"
      disabled={pending}
      className={pending ? 'loading' : ''}
    >
      {pending ? 'Envoi...' : children}
    </button>
  )
}

Dans le formulaire :

// app/signup/page.tsx
'use client'

import { useActionState } from 'react'
import { signup } from '@/app/actions'
import { SubmitButton } from '@/components/SubmitButton'

export default function SignupPage() {
  const [state, formAction] = useActionState(signup, { success: false, errors: {} })

  return (
    <form action={formAction}>
      {/* champs... */}

      <SubmitButton>S'inscrire</SubmitButton>

      {state.errors?.general && (
        <p className="error">{state.errors.general}</p>
      )}
    </form>
  )
}

Le bouton encapsule le chargement : désactivation anti double-clic, libellé « Envoi… », animation possible.

pending vs isPending ?

CaractéristiqueisPending (useActionState)pending (useFormStatus)
EmplacementComposant formulaireSous-composant du formulaire
Cas d’usageÉtat global du formulaireBouton d’envoi réutilisable
Flexibilitéstate + pendingpending uniquement

En pratique :

  • logique complexe, plusieurs états → useActionState
  • bouton générique → useFormStatus

Amélioration progressive

Les Server Actions supportent l’amélioration progressive : même sans JavaScript, le formulaire peut être soumis.

Next.js s’appuie sur la soumission native <form>. Avec JS, interception en requête AJAX ; sans JS, soumission classique.

Cas réels rares aujourd’hui, mais utile pour l’accessibilité et les crawlers. Rien à configurer : Next.js gère.

Sécurité et bonnes pratiques

Sécurité des Server Actions

Partie souvent négligée. Beaucoup pensent : « c’est côté serveur, donc sécurisé ». Faux.

Une Server Action est un endpoint API public. Next.js génère un ID difficile à deviner — obfuscation, pas sécurité réelle. Un regard dans l’onglet Réseau suffit pour trouver l’ID et appeler l’action manuellement.

Protections intégrées Next.js :

  1. CSRF : POST uniquement ; vérification Origin/Host
  2. ID d’action chiffré : difficile à énumérer
  3. Variables de closure chiffrées si utilisées dans l’action

Insuffisant. Vous devez aussi :

1. Valider les entrées

Ne jamais faire confiance au client. Zod, comme vu plus haut.

2. Authentifier

Vérifier la session pour toute action protégée.

3. Autoriser

Connexion ≠ droits. L’utilisateur A ne doit pas supprimer les données de B.

Exemple complet :

// app/actions.ts
'use server'

import { cookies } from 'next/headers'
import { z } from 'zod'

const DeletePostSchema = z.object({
  postId: z.string().min(1),
})

export async function deletePost(formData: FormData) {
  const rawData = {
    postId: formData.get('postId'),
  }

  const result = DeletePostSchema.safeParse(rawData)
  if (!result.success) {
    return { success: false, error: 'Requête invalide' }
  }

  const { postId } = result.data

  const cookieStore = await cookies()
  const sessionToken = cookieStore.get('session')?.value

  if (!sessionToken) {
    return { success: false, error: 'Veuillez vous connecter' }
  }

  const currentUser = await getUserFromSession(sessionToken)
  if (!currentUser) {
    return { success: false, error: 'Session expirée' }
  }

  const post = await getPost(postId)
  if (!post) {
    return { success: false, error: 'Article introuvable' }
  }

  if (post.authorId !== currentUser.id) {
    return { success: false, error: 'Vous n\'avez pas le droit de supprimer cet article' }
  }

  await deletePostFromDB(postId)

  return { success: true, message: 'Suppression réussie' }
}

Validation → authentification → autorisation → exécution. Aucune étape ne peut manquer.

Outil utile : next-safe-action pour centraliser validation, auth et erreurs :

import { createSafeActionClient } from 'next-safe-action'

const actionClient = createSafeActionClient({
  async middleware() {
    const session = await getSession()
    if (!session) {
      throw new Error('Non connecté')
    }
    return { userId: session.userId }
  },
})

export const deletePost = actionClient
  .schema(DeletePostSchema)
  .action(async ({ parsedInput, ctx }) => {
    const { postId } = parsedInput
    const { userId } = ctx

    // suppression...
  })

Toutes les actions protégées partagent la même logique.

Rappel : une Server Action est un endpoint API. Mêmes exigences de sécurité qu’une route API classique.

Cas pratique : formulaire avec authentification

Formulaire de commentaire réservé aux utilisateurs connectés :

// app/actions.ts
'use server'

import { cookies } from 'next/headers'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'

const CommentSchema = z.object({
  postId: z.string(),
  content: z.string().min(1, 'Le commentaire ne peut pas être vide').max(500, 'Maximum 500 caractères'),
})

export async function addComment(formData: FormData) {
  const rawData = {
    postId: formData.get('postId'),
    content: formData.get('content'),
  }

  const result = CommentSchema.safeParse(rawData)
  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors,
    }
  }

  const cookieStore = await cookies()
  const sessionToken = cookieStore.get('session')?.value

  if (!sessionToken) {
    return {
      success: false,
      error: 'Connectez-vous pour commenter',
    }
  }

  const user = await getUserFromSession(sessionToken)
  if (!user) {
    return {
      success: false,
      error: 'Session expirée, reconnectez-vous',
    }
  }

  const { postId, content } = result.data

  await saveComment({
    postId,
    content,
    authorId: user.id,
    authorName: user.name,
    createdAt: new Date(),
  })

  revalidatePath(`/posts/${postId}`)

  return {
    success: true,
    message: 'Commentaire publié',
  }
}

Composant client :

// app/posts/[id]/CommentForm.tsx
'use client'

import { useActionState } from 'react'
import { addComment } from '@/app/actions'
import { SubmitButton } from '@/components/SubmitButton'

export function CommentForm({ postId }: { postId: string }) {
  const [state, formAction] = useActionState(addComment, {
    success: false,
    errors: {},
  })

  return (
    <form action={formAction}>
      <input type="hidden" name="postId" value={postId} />

      <textarea
        name="content"
        placeholder="Votre commentaire..."
        rows={4}
        required
      />

      {state.errors?.content && (
        <p className="error">{state.errors.content[0]}</p>
      )}

      {state.error && (
        <p className="error">{state.error}</p>
      )}

      {state.success && (
        <p className="success">{state.message}</p>
      )}

      <SubmitButton>Publier le commentaire</SubmitButton>
    </form>
  )
}

Zod, session, useActionState, revalidatePath, bouton avec chargement — flux prêt pour la production.

Techniques avancées

Passer des paramètres supplémentaires

Parfois il faut transmettre plus que les champs visibles — par ex. l’ID d’article à éditer.

Champ caché :

<input type="hidden" name="postId" value={postId} />

Ou bind, plus élégant :

// app/actions.ts
'use server'

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

  await updatePostInDB(postId, { title, content })

  return { success: true }
}

Côté client :

// app/posts/[id]/edit/page.tsx
'use client'

import { updatePost } from '@/app/actions'

export default function EditPost({ postId }: { postId: string }) {
  const updatePostWithId = updatePost.bind(null, postId)

  return (
    <form action={updatePostWithId}>
      <input type="text" name="title" required />
      <textarea name="content" required />
      <button type="submit">Mettre à jour</button>
    </form>
  )
}

bind(null, postId) fixe postId comme premier argument ; FormData arrive en second.

Idéal pour édition, suppression, etc.

Revalidation des données

Après mutation, le cache des pages concernées peut être obsolète. Deux fonctions Next.js :

1. revalidatePath

Par chemin :

import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  // création...

  revalidatePath('/')
  revalidatePath(`/posts/${newPostId}`)

  return { success: true }
}

2. revalidateTag

Par tag (défini sur le fetch) :

fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] }
})

import { revalidateTag } from 'next/cache'

export async function createPost(formData: FormData) {
  // création...

  revalidateTag('posts')

  return { success: true }
}

Quand utiliser quoi ?

  • Peu de chemins fixesrevalidatePath
  • Données partagées sur plusieurs pagesrevalidateTag

Je privilégie revalidatePath ; les tags quand une action touche beaucoup de pages.

Mise à jour optimiste

Pour des actions quasi toujours réussies (like, favori) : mettre à jour l’UI immédiatement, soumettre ensuite.

React 19 propose useOptimistic :

'use client'

import { useOptimistic } from 'react'
import { likePost } from '@/app/actions'

export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const [optimisticLikes, setOptimisticLikes] = useOptimistic(initialLikes)

  async function handleLike() {
    setOptimisticLikes(optimisticLikes + 1)

    await likePost(postId)
  }

  return (
    <button onClick={handleLike}>
      👍 {optimisticLikes}
    </button>
  )
}

Clic → compteur +1 sans attendre le serveur.

Réservez cela aux opérations très fiables ; en cas d’échec, le rollback complique l’UX.

Conclusion

Trois points à retenir :

  1. Les Server Actions simplifient les formulaires, sans tout remplacer. En interne oui ; API publiques → Route Handlers.

  2. La sécurité vous appartient. Validation, auth, autorisation — le framework ne suffit pas.

  3. Les détails UX comptent. Chargement, erreurs, optimisme… useActionState et useFormStatus couvrent l’essentiel.

Commencez simple : une Server Action, Zod, un état de chargement — vous maîtrisez déjà 80 %. Cache, optimisme : consultez la doc au besoin.

Next.js et React évoluent vite ; les API peuvent changer. Surveillez la documentation officielle.

Essayez dans votre projet : la prochaine soumission de formulaire sera peut-être bien plus simple qu’avant.

Flux complet de traitement de formulaire avec Server Actions

De la création d'une Server Action à la validation et à la gestion d'état

⏱️ Estimated time: 30 min

  1. 1

    Step 1: Créer une Server Action

    Créez une Server Action dans app/actions.ts :

    1. Marqueur au niveau fichier : ajoutez 'use server' en tête de fichier
    2. Définir la fonction : export async function actionName(formData: FormData)
    3. Récupérer les données : formData.get('fieldName')
    4. Retourner un résultat : { success: boolean, errors?: {}, message?: string }

    Exemple :
    ```typescript
    'use server'
    export async function signup(formData: FormData) {
    const name = formData.get('name') as string
    // logique...
    return { success: true, message: 'Inscription réussie' }
    }
    ```
  2. 2

    Step 2: Ajouter la validation Zod

    Validez côté serveur avec Zod :

    1. Installer : npm install zod
    2. Définir le schéma : const SignupSchema = z.object({ name: z.string().min(2), email: z.string().email() })
    3. Valider : const result = SignupSchema.safeParse(rawData)
    4. Gérer les erreurs : if (!result.success) return { success: false, errors: result.error.flatten().fieldErrors }

    Points clés :
    • safeParse ne lance pas d'exception, retourne { success, data/error }
    • flatten().fieldErrors produit { field: ['error1'] }
    • En cas d'échec, retournez des erreurs structurées pour l'affichage client
  3. 3

    Step 3: Gérer l'état avec useActionState

    Dans un composant client :

    1. Importer : import { useActionState } from 'react'
    2. État initial : const initialState = { success: false, errors: {} }
    3. Hook : const [state, formAction, isPending] = useActionState(action, initialState)
    4. Formulaire : <form action={formAction}>
    5. Erreurs : {state.errors?.field && <p>{state.errors.field[0]}</p>}
    6. Chargement : <button disabled={isPending}>{isPending ? 'Envoi...' : 'Envoyer'}</button>

    Flux :
    • Soumission → action → résultat → state mis à jour → re-render
  4. 4

    Step 4: Authentification et autorisation

    Ajoutez des contrôles de sécurité dans la Server Action :

    1. Validation des entrées avec Zod
    2. Authentification : vérifier le token de session
    ```typescript
    const cookieStore = await cookies()
    const sessionToken = cookieStore.get('session')?.value
    if (!sessionToken) return { success: false, error: 'Veuillez vous connecter' }
    ```
    3. Autorisation : vérifier les droits
    ```typescript
    const post = await getPost(postId)
    if (post.authorId !== currentUser.id) {
    return { success: false, error: 'Accès refusé' }
    }
    ```
    4. Exécuter la logique métier après validation

    Rappel : les Server Actions ne sont pas magiques ; les contrôles de sécurité sont manuels
  5. 5

    Step 5: Optimiser l'expérience utilisateur

    États de chargement et gestion d'erreurs :

    1. useFormStatus (dans un composant bouton) :
    ```typescript
    'use client'
    import { useFormStatus } from 'react-dom'
    export function SubmitButton() {
    const { pending } = useFormStatus()
    return <button disabled={pending}>...</button>
    }
    ```
    2. revalidatePath pour rafraîchir le cache :
    ```typescript
    import { revalidatePath } from 'next/cache'
    revalidatePath('/posts')
    ```
    3. Mise à jour optimiste (optionnel, opérations à fort taux de succès) :
    ```typescript
    const [optimisticState, setOptimisticState] = useOptimistic(initialState)
    ```

    Bonnes pratiques :
    • Logique complexe → useActionState
    • Bouton réutilisable → useFormStatus
    • Après succès → rafraîchir le cache des pages concernées

FAQ

Quelle différence entre Server Actions et API Routes ? Quand utiliser l'un ou l'autre ?
Les Server Actions conviennent aux soumissions internes et mutations de données : POST uniquement, typage fort, peu de code. Les API Routes servent les API REST publiques, les requêtes GET et l'intégration tierce. En bref : Server Actions en interne, API Routes vers l'extérieur.
Les Server Actions sont-elles sûres ? Quelles mesures adopter ?
Elles s'exécutent côté serveur, mais la sécurité reste manuelle : 1) validation des entrées (Zod), 2) authentification (session), 3) autorisation (droits). Le framework fournit une protection CSRF de base ; ne comptez pas sur lui seul.
Quelle différence entre useActionState et useFormStatus ?
isPending de useActionState convient dans le composant formulaire et expose state + pending. pending de useFormStatus doit être utilisé dans un sous-composant du formulaire (ex. bouton) et ne fournit que l'état pending. Logique complexe → useActionState ; bouton isolé → useFormStatus.
Comment passer des paramètres en dehors des champs du formulaire ?
Deux options : 1) champ caché <input type="hidden" name="postId" value={postId} />, 2) bind : const actionWithId = action.bind(null, postId) puis <form action={actionWithId}>. bind est généralement plus élégant.
Comment rafraîchir les données après soumission ?
revalidatePath('/posts') par chemin, ou revalidateTag par tag (après avoir tagué le fetch). Chemin fixe et peu de pages → revalidatePath ; données dispersées → revalidateTag.
Quand utiliser une mise à jour optimiste ?
Pour des opérations à très fort taux de succès (like, favori) avec useOptimistic : UI immédiate, soumission en arrière-plan. Si l'échec est probable, évitez — le rollback complique l'UX.

11 min de lecture · Publié le: 19 déc. 2025 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog