Les Server Actions ont fondamentalement changé la façon d'écrire des mutations dans Next.js App Router. Depuis React 19 et Next.js 14+, elles permettent d'exécuter du code serveur directement depuis les composants clients sans écrire de Route Handler intermédiaire. Résultat concret : moins de surface d'API, un typage bout-en-bout sans ceremony, et une UX avec états de chargement natifs.
Mais cette simplicité apparente cache des subtilités importantes : revalidation mal configurée qui stale les données, gestion d'erreurs qui expose des informations sensibles côté client, ou mutations non protégées accessibles par n'importe qui via POST. Ce guide couvre les patterns solides pour éviter ces pièges en production.
On s'appuie ici sur Next.js 15 et React 19 avec TypeScript strict. Les concepts s'appliquent à Next.js 14 avec quelques adaptations mineures sur les hooks.
Anatomie d'une Server Action
Une Server Action est une fonction asynchrone marquée 'use server' qui s'exécute exclusivement côté serveur, même appelée depuis un composant client. Next.js génère automatiquement un endpoint POST unique pour chaque action — invisible pour le développeur, mais auditable dans l'onglet réseau des DevTools.
La signature (prevState, formData) est requise pour l'intégration avec useActionState côté client. prevState contient le résultat retourné par l'exécution précédente, ce qui permet d'afficher erreurs et succès sans rechargement de page.
// app/actions/user.ts
'use server'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
import { userUpdateSchema } from '@/lib/validations/user'
import type { ActionState } from '@/types/actions'
export async function updateUser(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
// Toujours valider les données avant de toucher la DB
const parsed = userUpdateSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
})
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors }
}
await db.user.update({
where: { id: parsed.data.id },
data: parsed.data,
})
// Invalider précisément le cache de la page affectée
revalidatePath('/dashboard/profile')
return { success: true }
}Un point souvent manqué : le fichier app/actions/user.ts entier devient un module serveur dès qu'il contient 'use server' en en-tête. Toutes les fonctions exportées depuis ce fichier sont automatiquement des Server Actions — pas besoin de répéter la directive dans chaque fonction.
Revalidation : les trois primitives à maîtriser
La revalidation est le sujet le plus sous-documenté des Server Actions. Next.js expose trois fonctions, chacune avec un périmètre différent — les utiliser à tort inverse l'effet escompté.
revalidatePath(path, type?) invalide toutes les entrées du cache associées à un chemin. Avec type: 'layout', elle invalide aussi les layouts parents de ce chemin. À utiliser après une mutation qui affecte une page entière. Attention : revalidatePath('/') invalide le cache de toute l'application — coûteux et rarement justifié.
revalidateTag(tag) invalide toutes les requêtes fetch marquées avec un tag donné. Plus chirurgical : on invalide uniquement les données produits sans toucher au reste de la page. Nécessite que les fetch upstream aient été taguées avec { next: { tags: ['products'] } }. C'est l'approche recommandée pour les applications avec un cache granulaire.
redirect(url) redirige après mutation. Contrairement aux apparences, redirect lève une erreur interne que Next.js intercepte. Ne jamais l'envelopper dans un try/catch — le catch intercepterait l'erreur avant Next.js :
// Mauvais — redirect ne sera jamais exécuté côté Next.js
try {
await db.post.create({ data })
redirect('/posts') // lève NEXT_REDIRECT — intercepté par le catch !
} catch (e) {
return { error: { _global: ['Erreur'] } }
}
// Correct — redirect placé hors du try/catch
try {
await db.post.create({ data })
} catch (e) {
console.error('[createPost]', e)
return { error: { _global: ['Erreur lors de la création'] } }
}
redirect('/posts') // exécuté uniquement si pas d'erreurUn pattern de revalidation précise pour une mutation qui touche plusieurs surfaces : combiner revalidateTag + revalidatePath ciblé, puis redirect vers la page mise à jour.
Gestion d'erreurs sans fuites d'information
Par défaut, si une Server Action lève une exception non gérée, Next.js retourne un message générique en production mais logue le détail complet côté serveur. C'est le comportement attendu. Le danger vient des patterns qui exposent involontairement les entrailles du système :
// Mauvais — expose le message d'erreur Prisma brut au client
} catch (e) {
return { error: { _global: [(e as Error).message] } }
// Client voit : "Unique constraint failed on the fields: (`email`)"
}
// Correct — message générique pour l'utilisateur, log structuré côté serveur
} catch (e) {
console.error('[updateUser] Unexpected error', {
error: e,
correlationId: crypto.randomUUID(),
})
return { error: { _global: ['Une erreur est survenue. Réessayez dans quelques instants.'] } }
}Pour les erreurs métier prévisibles (email déjà utilisé, quota dépassé, ressource introuvable), les intercepter explicitement avec un message adapté à l'utilisateur. Les erreurs imprévues méritent un log structuré et un message générique. Cette distinction améliore à la fois la sécurité et l'expérience de débogage.
Un type ActionState centralisé rend cela cohérent à travers toute l'application :
// types/actions.ts
export type ActionState<T = unknown> = {
success?: boolean
data?: T
error?: {
_global?: string[]
[field: string]: string[] | undefined
}
}Ce type est compatible avec useActionState et permet d'afficher des erreurs par champ (issues de la validation Zod) ou globales (erreurs réseau, contraintes DB). Voir l'article sur la validation avec Zod dans Next.js App Router pour le câblage complet des erreurs de formulaire.
Sécuriser les Server Actions
Une idée reçue critique : les Server Actions ne sont pas protégées par défaut. Elles sont accessibles via POST depuis n'importe quel client. L'authentification et l'autorisation doivent être vérifiées à l'intérieur de chaque action — pas seulement dans les layouts ou middlewares.
'use server'
import { auth } from '@/lib/auth'
export async function deletePost(postId: string) {
// 1. Authentification
const session = await auth()
if (!session?.user) {
throw new Error('Non authentifié')
}
// 2. Autorisation — vérifier l'ownership de la ressource
const post = await db.post.findUnique({
where: { id: postId },
select: { authorId: true },
})
if (!post || post.authorId !== session.user.id) {
throw new Error('Non autorisé')
}
await db.post.delete({ where: { id: postId } })
revalidatePath('/posts')
}Pour éviter de répéter ce boilerplate, un wrapper withAuth centralise la vérification :
// lib/actions/with-auth.ts
export function withAuth<T extends unknown[], R>(
action: (session: Session, ...args: T) => Promise<R>
) {
return async (...args: T): Promise<R> => {
const session = await auth()
if (!session?.user) throw new Error('Non authentifié')
return action(session, ...args)
}
}
// app/actions/post.ts
export const deletePost = withAuth(async (session, postId: string) => {
const post = await db.post.findUnique({ where: { id: postId }, select: { authorId: true } })
if (!post || post.authorId !== session.user.id) throw new Error('Non autorisé')
await db.post.delete({ where: { id: postId } })
revalidatePath('/posts')
})Consulter l'article sur l'authentification Next.js avec Better Auth et Clerk pour la mise en place complète du système de session.
useActionState et useOptimistic : l'UX sans friction
React 19 introduit useActionState pour connecter une Server Action à l'état local d'un composant sans gestion manuelle d'un état loading. Couplé à useOptimistic, il permet des mises à jour instantanées avec rollback automatique en cas d'erreur côté serveur.
'use client'
import { useActionState, useOptimistic } from 'react'
import { updatePost } from '@/app/actions/post'
import type { Post } from '@/types'
export function PostTitleForm({ post }: { post: Post }) {
const [state, action, isPending] = useActionState(updatePost, null)
const [optimisticTitle, setOptimisticTitle] = useOptimistic(post.title)
return (
<form
action={async (formData: FormData) => {
setOptimisticTitle(formData.get('title') as string)
await action(formData)
}}
>
<input name="title" defaultValue={optimisticTitle} disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? 'Sauvegarde…' : 'Sauvegarder'}
</button>
{state?.error?._global?.map((err) => (
<p key={err} className="text-red-500 text-sm">{err}</p>
))}
</form>
)
}isPending est true pendant toute la durée de l'exécution serveur — plus besoin de useState manuel pour le loading. Si l'action échoue, useOptimistic revient automatiquement à la valeur précédente. C'est le pattern recommandé pour tout formulaire de mutation en App Router. Voir l'article sur les hooks React 19 dans Next.js pour les patterns avancés.
Mutations depuis les Server Components
Les Server Actions peuvent aussi être invoquées directement depuis des Server Components, sans useActionState. C'est le cas des formulaires simples qui n'ont pas besoin d'état côté client :
// app/posts/[id]/page.tsx (Server Component)
import { deletePost } from '@/app/actions/post'
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await db.post.findUnique({ where: { id: params.id } })
const deletePostWithId = deletePost.bind(null, post.id)
return (
<article>
<h1>{post.title}</h1>
<form action={deletePostWithId}>
<button type="submit">Supprimer l'article</button>
</form>
</article>
)
}.bind(null, postId) permet de passer des arguments fixes à une Server Action via form action. La valeur bindée est sérialisée par Next.js côté serveur — elle n'est pas exposée en clair dans le HTML côté client, contrairement à un <input type="hidden">.
Server Components + .bind() convient aux actions simples sans feedback UI. Dès qu'on a besoin d'un spinner, d'erreurs inline ou de mises à jour optimistes, passer à un Client Component avec useActionState.
En pratique
Sur les projets Kreio, les Server Actions sont organisées par domaine fonctionnel (app/actions/user.ts, app/actions/post.ts, app/actions/order.ts) avec le type ActionState centralisé dans types/actions.ts. Chaque action suit le même contrat : valider avec Zod, gérer les erreurs explicitement, revalider précisément, retourner ActionState.
Pour les mutations qui touchent plusieurs surfaces de cache, on évite le revalidatePath large et on préfère la combinaison tags + paths ciblés :
// Revalidation précise après création d'un commentaire
revalidateTag(`post:${postId}:comments`) // invalide le compteur et la liste
revalidatePath(`/posts/${postId}`) // invalide la page article
// revalidatePath('/') — jamais : invalide tout le cache applicatifCette discipline de revalidation évite les sur-invalidations qui dégradent les performances de cache en production, surtout sur des pages à fort trafic.
Chez Kreio, agence Next.js basée à Évreux (Normandie), on applique ces patterns sur des projets clients en production. Besoin d'un audit ou d'un renfort tech ? Parlons-en.
Sources
- Next.js Docs — Server Actions and Mutations
- React 19 — useActionState
- Next.js Docs — revalidatePath
- Next.js Docs — revalidateTag
Cet article a été rédigé avec l'aide de l'IA et relu par un humain avant publication.