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

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éristique | Server Actions | API Routes |
|---|---|---|
| Usage | Soumission formulaire, mutations | API REST, appels externes |
| Méthodes HTTP | POST uniquement | GET/POST/PUT/DELETE, etc. |
| Typage | Natif | Types manuels |
| Appel | Appel direct de fonction | Requête fetch |
| Cas d’usage | Logique interne, formulaires | API publique, intégrations |
| Volume de code | Faible | Plus é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 :
'use server': indique à Next.js d’exécuter la fonction côté serveurformData.get(): récupère la valeur via l’attributnameaction={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 :
safeParsesans exception : retourne{ success: false, error: ... }en cas d’échecflatten().fieldErrors: format{ name: ['erreur1'], email: ['erreur2'] }pour l’affichage- 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 :
- Soumission → appel de
signup - Échec validation →
{ success: false, errors: {...} } useActionStatemet à jourstate- 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é :
isPendingde useActionState : dans le composant formulairependingde 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éristique | isPending (useActionState) | pending (useFormStatus) |
|---|---|---|
| Emplacement | Composant formulaire | Sous-composant du formulaire |
| Cas d’usage | État global du formulaire | Bouton d’envoi réutilisable |
| Flexibilité | state + pending | pending 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 :
- CSRF : POST uniquement ; vérification Origin/Host
- ID d’action chiffré : difficile à énumérer
- 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 fixes →
revalidatePath - Données partagées sur plusieurs pages →
revalidateTag
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 :
-
Les Server Actions simplifient les formulaires, sans tout remplacer. En interne oui ; API publiques → Route Handlers.
-
La sécurité vous appartient. Validation, auth, autorisation — le framework ne suffit pas.
-
Les détails UX comptent. Chargement, erreurs, optimisme…
useActionStateetuseFormStatuscouvrent 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
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
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
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
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
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 sont-elles sûres ? Quelles mesures adopter ?
Quelle différence entre useActionState et useFormStatus ?
Comment passer des paramètres en dehors des champs du formulaire ?
Comment rafraîchir les données après soumission ?
Quand utiliser une mise à jour optimiste ?
11 min de lecture · Publié le: 19 déc. 2025 · Mis à jour le: 27 juil. 2026
Guide complet Next.js
Si vous arrivez depuis la recherche, le plus rapide est de passer à l’article précédent ou suivant de cette série.
Précédent
Next.js SSR vs SSG vs ISR : guide pour choisir sa stratégie de rendu
Vous hésitez entre SSR, SSG et ISR dans Next.js ? Comparaison par scénarios, arbre de décision et solutions aux problèmes courants (ISR qui ne s'applique pas, premier affichage lent).
Partie 9 sur 51
Suivant
Middleware Next.js : guide pratique — matcher, limites Edge Runtime et pièges courants
Du bug en production à la solution complète : matcher, limites Edge Runtime et trois scénarios concrets pour éviter les pièges les plus fréquents avec Next.js Middleware.
Partie 11 sur 51



Commentaires
Connectez-vous avec GitHub pour laisser un commentaire