frontend / nextjs
Server Actions
Explication
Ce que vous allez apprendre
- Écrire une Server Action avec
"use server"et l'appeler depuis un<form> - Comprendre le progressive enhancement offert par
action={maServerAction} - Gérer l'état d'une mutation (erreurs,
isPending) avecuseActionState - Donner une sensation d'instantanéité avec
useOptimistic - Revérifier systématiquement les autorisations À L'INTÉRIEUR d'une Server Action
Dans quel contexte ?
Sur une application de gestion de tâches, un formulaire app/todos/page.tsx doit permettre d'ajouter un todo sans écrire d'API route dédiée ni de logique fetch côté client. Un autre écran doit aussi permettre de supprimer un todo d'un clic sur une icône poubelle — une action qui, si elle n'est pas protégée correctement, pourrait être appelée directement par n'importe qui connaissant son nom de fonction, même sans passer par l'interface. Cette leçon couvre les deux cas : la mutation simple via formulaire, et la sécurisation d'une action appelée hors formulaire.
Comment ça se passait avant
Pour qu'un formulaire modifie une donnée en base, il fallait créer toute une chaîne. Une API route (/api/todos), un fetch côté client pour l'appeler, la gestion manuelle du JSON envoyé et reçu — beaucoup de code répétitif pour une opération simple.
Les Server Actions simplifient radicalement ça. On écrit une fonction qui s'exécute CÔTÉ SERVEUR, mais qu'on peut appeler directement depuis un composant client ou un <form>, comme une fonction JavaScript normale.
Voyons comment ça fonctionne concrètement. La directive "use server" marque une fonction comme exécutable uniquement côté serveur. Next.js génère alors, en coulisses, tout le mécanisme réseau nécessaire pour l'appeler depuis le navigateur.
Un détail qui a son importance. Un formulaire utilisant action={maServerAction} fonctionne même si le JavaScript n'est pas encore chargé (progressive enhancement) — un vrai gain de robustesse comparé à un onSubmit classique.
Une fois la mutation en place, il faut gérer son état pendant qu'elle s'exécute. useActionState gère erreurs de validation et statut "en cours" (isPending), sans avoir à coder cette plomberie à la main.
On peut aller encore plus loin dans le confort d'utilisation. useOptimistic met à jour l'UI INSTANTANÉMENT, avant même que le serveur ait confirmé quoi que ce soit : l'élément ajouté apparaît tout de suite, éventuellement en semi-transparent le temps de la confirmation.
| Outil | Rôle | S'utilise dans |
|---|---|---|
"use server" | Marque une fonction exécutable uniquement côté serveur | Fichier d'actions ou fonction inline |
useActionState | Gère l'état d'une mutation (erreurs, isPending) | Client Component |
useOptimistic | Met à jour l'UI avant la réponse du serveur | Client Component |
Il reste un point de sécurité crucial à ne jamais oublier. Une Server Action est un point d'entrée public, exactement comme une API route classique — n'importe qui connaissant son existence pourrait tenter de l'appeler directement.
La règle à retenir absolument : revérifier les autorisations À L'INTÉRIEUR de l'action (session, propriétaire de la ressource), jamais se fier à ce que le formulaire côté client a déjà "vérifié".
Piège dangereux
Une action deleteTodo(id: string) sans vérification de session peut être appelée directement depuis la console du navigateur par n'importe quel visiteur connaissant son nom, en lui passant l'id d'une ressource qui ne lui appartient pas. Toujours vérifier session.userId par rapport au propriétaire réel de la ressource, DANS l'action, jamais seulement dans l'interface qui l'appelle.
Et la suite ? Cette leçon s'appuie sur le cache et la revalidation vus juste avant : après une mutation, revalidatePath est indispensable pour que l'utilisateur voie tout de suite le résultat de son action.
Commandes & code
Server Actions
Des fonctions serveur appelables directement depuis des composants clients ou des formulaires, sans écrire d'API route.
// app/todos/actions.ts
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const CreateTodoSchema = z.object({
title: z.string().min(1, "Titre requis").max(200),
});
export async function createTodo(formData: FormData) {
const parsed = CreateTodoSchema.safeParse({
title: formData.get("title"),
});
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors };
}
await db.insert("todos").values({ title: parsed.data.title, done: false });
revalidatePath("/todos"); // rafraîchit la liste après mutation
return { error: null };
}// app/todos/page.tsx — utilisation directe dans un <form> (fonctionne sans JS)
import { createTodo } from "./actions";
export default function TodosPage() {
return (
<form action={createTodo}>
<input type="text" name="title" required />
<button type="submit">Ajouter</button>
</form>
);
}// useActionState — état de la mutation (erreurs, pending) côté client
"use client";
import { useActionState } from "react";
import { createTodo } from "./actions";
const initialState = { error: null };
export default function TodoForm() {
const [state, formAction, isPending] = useActionState(createTodo, initialState);
return (
<form action={formAction}>
<input type="text" name="title" required />
{state.error?.title && <p className="error">{state.error.title[0]}</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Ajout..." : "Ajouter"}
</button>
</form>
);
}// useOptimistic — mise à jour instantanée de l'UI avant la réponse serveur
"use client";
import { useOptimistic, useRef } from "react";
import { createTodo } from "./actions";
interface Todo {
id: string;
title: string;
pending?: boolean;
}
export default function OptimisticTodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTitle: string) => [
...state,
{ id: crypto.randomUUID(), title: newTitle, pending: true },
]
);
const formRef = useRef<HTMLFormElement>(null);
async function handleAction(formData: FormData) {
const title = formData.get("title") as string;
addOptimisticTodo(title);
formRef.current?.reset();
await createTodo(formData);
}
return (
<>
<form ref={formRef} action={handleAction}>
<input type="text" name="title" required />
<button type="submit">Ajouter</button>
</form>
<ul>
{optimisticTodos.map((t) => (
<li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}>
{t.title}
</li>
))}
</ul>
</>
);
}// Server Action appelée directement (pas dans un <form>) — ex. bouton de suppression
"use server";
export async function deleteTodo(id: string) {
const session = await getSession();
if (!session) throw new Error("Non authentifié"); // sécurité : re-vérifier côté serveur
await db.delete("todos").where({ id, userId: session.userId });
revalidatePath("/todos");
}
import { revalidatePath } from "next/cache";
async function getSession() { return { userId: "1" }; }Résumé
"use server"marque une fonction exécutable uniquement côté serveur, appelable depuis le client.- Toujours revalider les autorisations côté serveur dans l'action : le client ne fait pas foi.
useActionStategère erreurs/pending,useOptimisticdonne une UI instantanée.revalidatePath/revalidateTagsynchronisent le cache après une mutation.
Exercices pratiques
Mission : la suppression de todo trouvée dans la console
Objectif : Sécuriser une Server Action vulnérable et synchroniser le cache après une mutation.
Contexte
Un audit de sécurité découvre qu'un visiteur peut ouvrir la console du navigateur, importer directement deleteTodo et l'appeler avec l'id d'un todo appartenant à un autre utilisateur — l'action actuelle ne vérifie ni session ni propriétaire :
"use server";
export async function deleteTodo(id: string) {
await db.delete("todos").where({ id });
}Par ailleurs, après un ajout de todo réussi via createTodo, la liste affichée reste périmée tant que la page n'est pas rechargée manuellement.