Server Actions : traiter les formulaires sans API séparée
1. Vue d’ensemble
Autrefois, pour traiter un formulaire dans Next.js, vous deviez écrire une route API (app/api/.../route.ts), puis appeler fetch depuis le client pour soumettre les données. Server Actions élimine cette étape intermédiaire : vous écrivez une fonction qui s’exécute sur le serveur, vous la connectez directement à l’attribut action du <form>, et c’est fait.
En lisant cet article, vous apprendrez à déclarer une Server Action, l’utiliser pour valider un formulaire, gérer les états pending/erreur avec useFormState et useFormStatus, revalider les données après la soumission, et éviter quelques erreurs courantes lors de la migration d’une route API.
Note : Server Actions est une fonctionnalité de l’App Router (Next.js 13.4+), stable depuis Next.js 14. Si vous utilisez encore Pages Router, cet article ne s’applique pas.
2. Votre première Server Action
Une Server Action est une fonction async avec la directive "use server", qui peut être placée dans un fichier distinct ou directement dans un composant (si ce composant est un Server Component).
// app/actions/customer-actions.ts
"use server";
export async function createCustomer(formData: FormData) {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
console.log("Création du client :", name, email);
// Simulation d'une sauvegarde en BDD
}
// app/customers/new/page.tsx
import { createCustomer } from "@/app/actions/customer-actions";
export default function NewCustomerPage() {
return (
<form action={createCustomer}>
<input name="name" placeholder="Nom du client" />
<input name="email" placeholder="Email" />
<button type="submit">Créer</button>
</form>
);
}
"use server"au début du fichier indique à Next.js que toutes les exports dans ce fichier sont des Server Actions, qui s’exécutent uniquement sur le serveur et ne sont pas regroupées dans le JS envoyé au client.action={createCustomer}connecte la fonction directement au formulaire. Next.js sérialise automatiquement le formulaire enFormDataet appelle la fonction sur le serveur lors de la soumission.- Pas besoin de
onSubmit, pas besoin depreventDefault, pas besoin defetch. Le formulaire fonctionne même si JavaScript n’a pas encore chargé (progressive enhancement).
Résultat lors de la soumission : les journaux apparaissent dans le terminal du serveur (pas la console du navigateur), car la fonction s’exécute sur le serveur.
3. Retourner un résultat au client avec useFormState
En pratique, vous devez afficher les erreurs de validation ou un message de succès. useFormState (de react-dom) aide la Server Action à retourner un state au client.
// app/actions/customer-actions.ts
"use server";
type ActionResult = { success: boolean; message: string };
export async function createCustomer(
_prevState: ActionResult,
formData: FormData
): Promise<ActionResult> {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
if (!name || !email) {
return { success: false, message: "Nom ou email manquant." };
}
if (!email.includes("@")) {
return { success: false, message: "Email invalide." };
}
// Simulation d'une sauvegarde en BDD
return { success: true, message: `Client ${name} créé.` };
}
// app/customers/new/page.tsx
"use client";
import { useFormState } from "react-dom";
import { createCustomer } from "@/app/actions/customer-actions";
const initialState = { success: false, message: "" };
export default function NewCustomerForm() {
const [state, formAction] = useFormState(createCustomer, initialState);
return (
<form action={formAction}>
<input name="name" placeholder="Nom du client" />
<input name="email" placeholder="Email" />
<button type="submit">Créer</button>
{state.message && (
<p style={{ color: state.success ? "green" : "red" }}>
{state.message}
</p>
)}
</form>
);
}
useFormState(action, initialState)enveloppe la Server Action, retournant[state, formAction].stateest la valeur retournée la plus récente par l’action,formActionest la fonction réellement connectée à<form action={...}>.- La Server Action reçoit un paramètre supplémentaire au début :
prevState— obligatoire quand on l’utilise avecuseFormState, même si on ne l’utilise pas. - Comme
useFormStateest un React hook, ce fichier doit être un Client Component, donc il faut"use client".
# Soumettre sans email
> Nom ou email manquant.
# Soumission valide
> Client Nguyễn Văn A créé.
4. État pending avec useFormStatus
Lors de la soumission, vous voulez désactiver le bouton et afficher un chargement. useFormStatus lit l’état pending du formulaire parent le plus proche — mais il doit être dans un composant enfant distinct.
// app/customers/new/submit-button.tsx
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Création en cours..." : "Créer"}
</button>
);
}
useFormStatusne fonctionne que si le composant est rendu à l’intérieur d’un<form>. Si vous placez<SubmitButton />directement dans le même composant qui contient le<form>, il lit toujours correctement car React trouve le formulaire parent le plus proche dans l’arborescence du DOM, pas le même composant.pendingesttruependant que la Server Action s’exécute sur le serveur, ce qui vous permet de désactiver le bouton pour éviter une double-soumission.
Note : N’appelez pas
useFormStatusdirectement dans le composant contenant le<form>en espérant qu’il connaisse l’état de ce formulaire — en fait ça fonctionne car il cherche selon l’arborescence DOM, mais le séparer en composant enfant est une bonne pratique pour éviter la confusion quand il y a plusieurs formulaires sur la même page.
5. Valider les données avec Zod
Valider manuellement avec des if/else devient rapidement un fouillis. Utilisez Zod pour déclarer un schéma clair.
<!-- Non applicable — c'est une dépendance npm, voir ci-dessous -->
npm install zod
// app/actions/customer-actions.ts
"use server";
import { z } from "zod";
const customerSchema = z.object({
name: z.string().min(2, "Le nom doit faire au moins 2 caractères."),
email: z.string().email("Email invalide."),
});
type ActionResult = {
success: boolean;
message: string;
errors?: Record<string, string[]>;
};
export async function createCustomer(
_prevState: ActionResult,
formData: FormData
): Promise<ActionResult> {
const parsed = customerSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
});
if (!parsed.success) {
return {
success: false,
message: "Données invalides.",
errors: parsed.error.flatten().fieldErrors,
};
}
return { success: true, message: `Client ${parsed.data.name} créé.` };
}
safeParseretourne un objet avecsuccessetdata/error, ne lève pas d’exception — parfait pour retourner les erreurs au client plutôt que de faire crasher l’action.flatten().fieldErrorsproduit un objet du type{ name: ["Le nom doit..."], email: [...] }, pratique pour afficher les erreurs directement sous chaque entrée.- Le schéma est défini une seule fois et peut être réutilisé pour la validation client (si nécessaire) et serveur — ce qui évite la duplication de logique.
6. Revalider les données après la soumission
Après avoir créé un client, la page de liste des clients (mise en cache par Next.js) doit être mise à jour. Utilisez revalidatePath ou revalidateTag.
// app/actions/customer-actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function createCustomer(formData: FormData) {
const name = formData.get("name") as string;
// ... sauvegarde en BDD
revalidatePath("/customers");
redirect("/customers");
}
revalidatePath("/customers")invalide le cache de cette page, au prochain accès Next.js la rendra à nouveau avec les nouvelles données.redirectlance une exception spéciale que Next.js intercepte pour rediriger — appelez-la aprèsrevalidatePath, et ne la mettez pas dans untry/catchqui l’engloutirait, car cela bloquerait la redirection.- Les deux fonctions ne peuvent être appelées que dans une Server Action ou un Server Component, pas depuis le client.
7. Appeler une Server Action depuis un Client Component (en dehors d’un formulaire)
Une Server Action ne doit pas nécessairement être attachée à un <form>. Vous pouvez l’appeler directement comme une fonction async, par exemple dans onClick.
// app/customers/[id]/delete-button.tsx
"use client";
import { useState } from "react";
import { deleteCustomer } from "@/app/actions/customer-actions";
export function DeleteButton({ customerId }: { customerId: string }) {
const [isDeleting, setIsDeleting] = useState(false);
async function handleClick() {
setIsDeleting(true);
await deleteCustomer(customerId);
setIsDeleting(false);
}
return (
<button onClick={handleClick} disabled={isDeleting}>
{isDeleting ? "Suppression en cours..." : "Supprimer"}
</button>
);
}
// app/actions/customer-actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function deleteCustomer(id: string) {
console.log("Suppression du client id :", id);
revalidatePath("/customers");
}
- Une Server Action est en fait compilée par Next.js en un endpoint POST caché, le client l’appelle via une requête réseau créée automatiquement — vous ne voyez pas le code fetch mais c’est bien là, vous pouvez le voir dans l’onglet Réseau.
- Cette approche est pratique pour les boutons de suppression, les likes, les basculements d’état — endroits où vous n’avez pas besoin d’un
<form>complet.
8. Écrire des tests pour une Server Action
Puisqu’une Server Action n’est qu’une fonction async acceptant FormData, vous pouvez la tester comme une fonction unitaire ordinaire, sans avoir besoin de mocker HTTP.
npm install -D vitest
// app/actions/customer-actions.test.ts
import { describe, it, expect } from "vitest";
import { createCustomer } from "./customer-actions";
function buildFormData(data: Record<string, string>): FormData {
const fd = new FormData();
for (const [key, value] of Object.entries(data)) {
fd.append(key, value);
}
return fd;
}
describe("createCustomer", () => {
it("retourne une erreur quand l'email est invalide", async () => {
const formData = buildFormData({ name: "An", email: "khong-hop-le" });
const result = await createCustomer({ success: false, message: "" }, formData);
expect(result.success).toBe(false);
expect(result.errors?.email).toContain("Email invalide.");
});
it("réussit avec des données valides", async () => {
const formData = buildFormData({ name: "An", email: "an@example.com" });
const result = await createCustomer({ success: false, message: "" }, formData);
expect(result.success).toBe(true);
expect(result.message).toContain("An");
});
});
buildFormDatasimuleFormDatacomme le navigateur l’envoie, sans avoir besoin de rendre un composant ou de mocker le serveur.- Comme une Server Action est une fonction pure (prend une entrée, retourne une sortie), les tests s’exécutent rapidement, sans avoir besoin de démarrer le serveur de développement Next.js.
Note : Si la Server Action appelle
redirectourevalidatePath, le test lèvera une erreur car ces fonctions ne fonctionnent que dans le contexte de requête réelle de Next.js. Séparez la logique métier (validation, calcul) de la partie qui appelleredirect/revalidatePathpour faciliter les tests.
9. Erreurs courantes
Oublier "use server" ou le placer au mauvais endroit. La directive doit être au début du fichier (ou au début de la fonction, si elle est déclarée inline dans un Server Component). Sans elle, Next.js traite la fonction comme du code ordinaire, la regroupe dans le client et génère une erreur à l’exécution.
Utiliser useFormState/useFormStatus dans un Server Component. Ces deux hooks appartiennent à react-dom, ils ne s’exécutent que dans un Client Component. Oublier "use client" causera une erreur de build.
Retourner des objets complexes (instances de classe, Date non sérialisées) depuis une Server Action. Les données retournées doivent être sérialisables sur le réseau, comme JSON. Retourner une Date brute peut parfois causer des erreurs silencieuses — convertissez-la en string d’abord.
Appeler une Server Action pour tout, y compris la logique qui n’a pas besoin du serveur. Si c’est juste une validation simple qui ne touche pas la BDD, la traiter directement sur le client sera plus rapide et évitera un aller-retour inutile.
Pas de solution de secours si JavaScript échoue. Server Actions supportent l’amélioration progressive, mais seulement si vous utilisez correctement <form action={...}>. Si vous appelez preventDefault() puis que vous appelez l’action manuellement, vous perdez cet avantage.
10. Quand ne pas utiliser Server Actions
Server Actions convient aux formulaires CRUD internes de votre application Next.js. Mais si vous avez besoin de :
- Une API publique que des applications mobiles ou des tiers puissent appeler — écrivez un vrai Route Handler (
route.ts), avec le versioning et l’authentification clairs. - Des réponses en streaming ou la gestion de très grands téléchargements de fichiers avec un contrôle fin — Route Handler est plus flexible.
- Une logique qui doit s’exécuter indépendamment, réutilisée en dehors du contexte Next.js (par exemple worker, tâche cron) — séparez-la en un service distinct, la Server Action ne fait que l’appeler.
11. Conclusion
Server Actions vous permet d’écrire des formulaires qui traitent les données sur le serveur sans avoir besoin de construire une route API séparée, ce qui réduit considérablement le code boilerplate fetch/JSON. Combinées avec useFormState pour afficher les erreurs, useFormStatus pour signaler l’état pending, et revalidatePath pour mettre à jour le cache, vous avez un flux CRUD propre qui fonctionne même si JavaScript n’a pas encore chargé. Utilisez Zod pour une validation claire, et pensez à séparer l’action en un vrai Route Handler si vous avez besoin d’une API publique pour l’extérieur.