Les Server Actions ont transformé la façon d'écrire des mutations en Next.js App Router. Plus besoin de créer une route API dédiée pour chaque opération : une fonction async marquée "use server" devient directement appelable depuis un formulaire ou un composant client. Moins de boilerplate, des types partagés entre client et serveur, un flux de données plus prévisible.
Mais "moins de boilerplate" ne signifie pas "moins de rigueur". Une Server Action mal structurée expose les mêmes risques qu'une API route bâclée : données non validées, erreurs opaques, état client désynchronisé. Ce guide couvre les patterns qui rendent les Server Actions robustes en production — de la validation Zod au rollback optimiste.
La documentation officielle Next.js positionne les Server Actions comme la primitive recommandée pour toutes les mutations de données dans l'App Router. Pourtant, la majorité des exemples qu'on trouve en ligne gèrent les erreurs avec un console.error et un alert(). Il y a de la marge.
Ce que les Server Actions changent vraiment
La différence fondamentale avec les API routes n'est pas syntaxique — elle est architecturale. Avec une API route, le client sérialise des données, les envoie via fetch, désérialise la réponse. Chaque étape peut introduire une discordance de types. Avec une Server Action, Next.js gère la sérialisation et TypeScript couvre l'intégralité du chemin : de l'input formulaire jusqu'au type de retour serveur.
Ce gain de cohérence a un corollaire important : les Server Actions s'exécutent toujours sur le serveur, jamais dans le navigateur. Elles ne peuvent pas accéder à localStorage, document, ou window. En revanche, elles ont accès direct aux variables d'environnement serveur, aux ORM (Prisma, Drizzle), aux SDK tiers — sans exposer de credentials au client.
Le deuxième changement structurant concerne le Progressive Enhancement. Un formulaire HTML qui pointe vers une Server Action via l'attribut action fonctionne sans JavaScript. C'est un avantage concret pour les formulaires critiques (inscription, contact, checkout) sur des connexions lentes ou avec JS désactivé. La majorité des implémentations ignorent cet avantage et wrappent tout dans des event handlers client — à tort.
Enfin, les Server Actions s'intègrent nativement au système de revalidation Next.js via revalidatePath et revalidateTag. Après une mutation réussie, une seule ligne invalide le cache des pages concernées, sans round-trip supplémentaire ni router.refresh() manuel.
Structurer une Server Action : la règle du point d'entrée mince
La première erreur courante est de placer la logique métier directement dans la Server Action. L'action doit être un point d'entrée mince — elle reçoit les données, valide, délègue au service, retourne un résultat structuré. La logique métier vit dans une couche services/ séparée, testable sans mocker Next.js.
// ✅ L'action délègue à un service — logique métier testable indépendamment
// Pourquoi : séparation des responsabilités, réutilisabilité du service,
// et signature compatible avec useActionState (React 19)
"use server";
import { projectService } from "@/services/project";
import { createProjectSchema } from "@/schemas/project";
import type { ActionState } from "@/types/action";
export async function createProjectAction(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const raw = Object.fromEntries(formData);
const parsed = createProjectSchema.safeParse(raw);
if (!parsed.success) {
return { success: false, errors: parsed.error.flatten().fieldErrors };
}
return projectService.create(parsed.data);
}
La signature (prevState, formData) => Promise<ActionState> est celle attendue par useActionState. L'adopter systématiquement, même pour les actions simples, garantit la compatibilité avec les patterns d'UI optimiste.
ActionState est un type discriminé défini une fois et réutilisé partout. Le discriminant success permet de gérer les deux cas sans try/catch et sans parser une string d'erreur : if (state.success) { /* accès à state.data */ } else { /* accès à state.errors */ }. Ce type vit dans types/action.ts et n'a aucune dépendance Next.js — il reste portable.
Valider les données côté serveur avec Zod
La validation côté serveur n'est pas optionnelle. Les données de formulaire peuvent être manipulées directement depuis les DevTools ou via des requêtes HTTP forgées. Ne jamais supposer qu'elles correspondent à ce que le client est censé envoyer.
Zod est le choix naturel ici : le même schéma sert à la fois pour la validation serveur et pour l'inférence de type TypeScript, ce qui évite toute duplication. Avec safeParse, la validation ne lève jamais d'exception — elle retourne un discriminated union prévisible.
// schemas/project.ts
// Pourquoi z.coerce.number() : FormData sérialise tout en string,
// coerce convertit "42" → 42 sans transformation manuelle
import { z } from "zod";
export const createProjectSchema = z.object({
name: z.string().min(2, "Minimum 2 caractères").max(60, "Maximum 60 caractères"),
slug: z
.string()
.regex(/^[a-z0-9-]+$/, "Lettres minuscules, chiffres et tirets uniquement"),
budget: z.coerce.number().min(0).optional(),
startDate: z.coerce.date().optional(),
});
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
Deux pièges fréquents avec FormData et Zod. Premier : les champs optionnels vides arrivent comme une string vide "" et non comme undefined — prévoir un .transform(v => v === "" ? undefined : v) avant .optional(). Second : les checkboxes non cochées n'envoient rien du tout — les parser avec .optional().transform(v => v === "on") pour obtenir un booléen propre.
Les erreurs de validation doivent être renvoyées au niveau du champ. parsed.error.flatten().fieldErrors retourne un objet { fieldName: string[] } directement utilisable pour afficher des erreurs inline dans le formulaire, sans logique de parsing côté client. Pour aller plus loin sur les patterns Zod avancés (transforms, refinements, unions discriminées), voir la validation des données côté serveur avec Zod.
Gérer les erreurs sans lever d'exception
Le pattern try/catch autour de chaque appel ORM dans la Server Action produit du code verbeux et des erreurs génériques. L'alternative : distinguer les erreurs attendues (conflit d'unicité, quota, permission) des erreurs inattendues (timeout DB, service externe indisponible) dans la couche service, et retourner dans les deux cas un ActionState structuré.
// services/project.ts
// Pourquoi vérifier error.code === "P2002" :
// Prisma expose les codes d'erreur PostgreSQL — P2002 = contrainte unique violée,
// cas prévisible qu'on transforme en message utilisateur exploitable
import { Prisma } from "@prisma/client";
import { db } from "@/lib/db";
import type { ActionState } from "@/types/action";
import type { CreateProjectInput } from "@/schemas/project";
export const projectService = {
async create(data: CreateProjectInput): Promise<ActionState> {
try {
await db.project.create({ data });
return { success: true };
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
return {
success: false,
errors: { slug: ["Ce slug est déjà utilisé"] },
};
}
// Erreur inattendue : log serveur + message générique au client
console.error("[projectService.create]", error);
return {
success: false,
errors: {},
message: "Une erreur est survenue. Réessayez dans quelques instants.",
};
}
},
};
Les erreurs inattendues sont loguées côté serveur et retournent un message générique — ne jamais exposer un stack trace ou un message Prisma brut au client. Ce pattern centralise la gestion d'erreur dans le service : les tests unitaires couvrent chaque cas en mockant Prisma, sans setup HTTP ni Next.js.
Mutations optimistes avec useOptimistic
L'UI optimiste consiste à refléter le résultat attendu d'une mutation immédiatement dans l'interface, avant la confirmation du serveur. Si la mutation réussit, l'état optimiste est confirmé. Si elle échoue, React revient automatiquement à l'état précédent. L'expérience utilisateur gagne en fluidité, surtout sur des connexions lentes.
React 19 introduit useOptimistic pour ce pattern sans librairie externe. Il prend l'état courant et une fonction de mise à jour, et retourne un état "optimiste" à afficher ainsi qu'une fonction pour le déclencher.
// components/TodoList.tsx — Client Component
// Pourquoi addOptimistic avant l'appel serveur :
// on préfère un état pending immédiat plutôt qu'un spinner bloquant
"use client";
import { useOptimistic, useActionState } from "react";
import { addTodoAction } from "@/actions/todo";
type Todo = { id: string; text: string; pending?: boolean };
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(state: Todo[], newTodo: Todo) => [...state, newTodo]
);
const [, dispatch] = useActionState(async (_: void, formData: FormData) => {
const text = formData.get("text") as string;
addOptimistic({ id: crypto.randomUUID(), text, pending: true });
await addTodoAction(formData);
}, undefined);
return (
<form action={dispatch}>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
{todo.text}
{todo.pending && " (enregistrement…)"}
</li>
))}
</ul>
<input name="text" required placeholder="Nouvelle tâche" />
<button type="submit">Ajouter</button>
</form>
);
}
L'opacité réduite et le label "(enregistrement…)" signalent à l'utilisateur que l'item est en cours de validation sans bloquer l'interface. Pour des mutations sur des listes paginées ou des états plus complexes, TanStack Query v5 offre des primitives d'optimistic updates plus robustes avec gestion de rollback automatique.
En pratique
Sur les projets Kreio, on structure les Server Actions en trois couches : actions/ (point d'entrée, validation, appel service), services/ (logique métier, accès DB, gestion d'erreurs ORM), schemas/ (schémas Zod partagés client/serveur). Cette séparation rend les services testables unitairement et les schémas réutilisables avec react-hook-form côté client via le resolver Zod.
Un pattern transversal utile : un HOF withAuth qui vérifie la session avant d'exécuter n'importe quelle action protégée, évitant de répéter la vérification dans chaque action individuelle.
// lib/with-auth.ts
// Pourquoi un HOF plutôt qu'une vérification inline dans chaque action :
// une seule implémentation à maintenir, impossible d'oublier la vérification
// dans une nouvelle action, et la session est typée à l'intérieur du callback
import { getServerSession } from "@/lib/auth";
import type { ActionState } from "@/types/action";
import type { Session } from "next-auth";
export function withAuth<TArgs extends unknown[]>(
action: (session: Session, ...args: TArgs) => Promise<ActionState>
) {
return async (...args: TArgs): Promise<ActionState> => {
const session = await getServerSession();
if (!session) {
return { success: false, errors: {}, message: "Session expirée" };
}
return action(session, ...args);
};
}
Pour l'authentification en profondeur dans Next.js App Router (Clerk, AuthJS, Better Auth), voir l'article dédié. Pour les hooks React 19 (useOptimistic, useActionState, use) dans leur ensemble, voir l'article sur les hooks React 19.
Sources
- Next.js Documentation — Server Actions and Mutations — nextjs.org
- React 19 — useOptimistic — react.dev
- Zod — Error Handling — zod.dev
- Prisma — Error Reference (codes P2xxx) — prisma.io