next.jsreact

Server Actions en Next.js : le formulaire qui fonctionne avant même que le JavaScript soit chargé

Server Actions en Next.js : le formulaire qui fonctionne avant même que le JavaScript soit chargé

Le motif classique du formulaire en React : onSubmit, event.preventDefault(), fetch('/api/todos', { method: 'POST', body: JSON.stringify(...) }), useState pour gérer l'état de chargement. Ça fonctionne – à une condition qu'on oublie facilement, car elle ne se révèle jamais sur son propre ordinateur avec une connexion rapide : le JavaScript doit avoir eu le temps de se charger, d'être analysé et exécuté, avant que le formulaire commence à réagir au moindre clic. Sur une connexion mobile lente, en pleine hydratation d'une grande page, ou si le script est bloqué par une extension de navigateur – l'utilisateur clique sur « Envoyer », et littéralement rien ne se passe. Pas d'erreur, pas de spinner – onClick n'existe simplement pas encore.

Les Server Actions de Next.js sont parfois présentées comme une syntaxe plus pratique pour exactement le même appel – « un fetch, sauf que vous n'avez pas besoin d'écrire d'API route ». C'est une simplification qui passe à côté de ce qui est réellement nouveau dans ce mécanisme : <form action={monAction}> s'appuie sur le mécanisme natif, existant depuis toujours, de soumission des formulaires par le navigateur, pas sur le JavaScript comme condition nécessaire. Cela change à la fois la façon dont cela fonctionne sous le capot, et les erreurs que commettent en pratique les équipes qui traitent "use server" comme un simple appel de fonction locale.

Ce que fait vraiment la directive "use server"

Quand vous marquez une fonction "use server", le compilateur de Next.js ne laisse pas son corps dans le paquet livré au navigateur. Il le retire et le remplace par une référence – un identifiant chiffré indiquant quelle fonction appeler sur le serveur. Ce qui arrive réellement chez le client, c'est un petit stub : « quand tu m'appelles, envoie un POST vers un endpoint RSC spécial avec cet identifiant et les arguments sérialisés ». La logique elle-même – une requête à la base de données, une validation, quoi que fasse la fonction – ne quitte jamais physiquement le serveur.

tsx

// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
export async function addTodoAction(prevState: unknown, formData: FormData) {
const title = formData.get("title");
if (typeof title !== "string" || title.trim().length === 0) {
return { error: "Le titre ne peut pas être vide." };
}
await db.todo.create({ data: { title: title.trim() } });
revalidatePath("/todos");
return { error: null };
}

Quand vous attachez cette fonction à <form action={addTodoAction}>, il se passe quelque chose qu'un simple fetch n'a jamais offert gratuitement : ça fonctionne avant même que le moindre JavaScript s'exécute. Le navigateur sait depuis toujours envoyer un formulaire en POST natif si l'attribut action pointe vers une URL – React 19 et Next.js étendent cela pour que action puisse aussi être une fonction serveur, et le navigateur emprunte quand même sa voie native si le JS n'est pas encore prêt. Une fois le JavaScript en place, Next.js intercepte cet événement et fait la même chose via fetch en arrière-plan, sans recharger la page, avec une mise à jour fluide de l'UI. C'est du progressive enhancement intégré au framework, pas quelque chose que vous devez programmer vous-même – exactement le même mécanisme évoqué à propos du RouteAnnouncer dans l'article sur l'accessibilité, mais appliqué ici à la mutation de données plutôt qu'à la navigation.

Exemple pratique : un formulaire avec une gestion d'état complète, sans fetch manuel

React 19 a ajouté useActionState spécifiquement pour ce motif – il relie l'appel d'une action serveur à l'état renvoyé par sa dernière exécution, sans useState manuel pour stocker l'erreur ou le statut de chargement.

tsx

"use client";
import { useActionState } from "react";
import { addTodoAction } from "./actions";
const initialState = { error: null };
const TodoForm = () => {
const [state, formAction, isPending] = useActionState(
addTodoAction,
initialState,
);
return (
<form action={formAction}>
<input type="text" name="title" placeholder="Nouvelle tâche" required />
<button type="submit" disabled={isPending}>
{isPending ? "Ajout…" : "Ajouter"}
</button>
{state.error && <p className="form-error">{state.error}</p>}
</form>
);
};
export default TodoForm;

Plusieurs choses se produisent ici simultanément, sans une ligne de code consacrée à la gestion manuelle de la requête. isPending reflète l'état réel de la soumission du formulaire – y compris ce POST natif, antérieur à l'hydratation, pas seulement les appels côté client. state.error est directement ce que la fonction serveur a renvoyé – aucun parsing de réponse JSON, aucun try/catch autour d'un fetch. revalidatePath("/todos") dans l'action demande à Next.js de rafraîchir le cache de cette route, donc la liste des tâches sur la page affichera la nouvelle entrée sans rafraîchissement manuel de l'état client – c'est le framework, et non vous, qui décide quand et comment récupérer le nouveau payload RSC.

Pièges et bonnes pratiques

  • Chaque fonction "use server" est un endpoint HTTP public – pas une fonction privée. C'est l'erreur de sécurité la plus fréquente avec les Server Actions : puisque rien dans l'UI n'y renvoie explicitement, elle paraît « cachée ». Elle ne l'est pas – le compilateur génère pour elle un identifiant d'action permanent, appelable de l'extérieur, donc quiconque le découvre (par exemple en observant le trafic réseau dans DevTools) peut l'appeler directement, en contournant votre UI et ses hypothèses. La validation des données d'entrée et la vérification des autorisations (session, rôle de l'utilisateur) doivent se faire à l'intérieur même de l'action, exactement comme dans un endpoint API classique – ne comptez jamais sur le fait que « le bouton est invisible pour les non-connectés ».
  • Les arguments et la valeur de retour doivent être sérialisables. Les Server Actions transmettent les données sur le réseau via le format de sérialisation de React (plus riche que JSON – il gère notamment Date, Map, FormData transmis directement depuis un formulaire), mais ne laissent toujours pas passer de fonctions, d'instances de classes ou de références à des objets DOM. Si vous utilisez .bind() pour figer un argument supplémentaire lors de l'appel côté client (addTodoAction.bind(null, listId)), rappelez-vous que listId doit lui aussi pouvoir être sérialisé.
  • C'est toujours une vraie requête réseau, pas un appel de fonction local. On l'oublie facilement, car syntaxiquement await monAction(donnees) ressemble exactement à l'appel d'une fonction JS ordinaire. Sous le capot, c'est toujours un aller-retour complet : sérialisation, HTTP, désérialisation de l'autre côté. Appeler une Server Action en boucle à chaque caractère saisi dans un champ texte (par exemple pour une validation en direct) transforme votre formulaire en générateur de requêtes, pas en champ réactif – pour ce type de cas, le debounce est tout aussi nécessaire que pour n'importe quel autre appel réseau.
  • L'absence de revalidatePath/revalidateTag laisse l'UI avec un cache obsolète. Next.js met par défaut en cache les données des pages – une action qui enregistre quelque chose en base sans indiquer au framework quelle route rafraîchir aboutit fréquemment au signalement « c'est enregistré, mais ça ne s'affiche pas » – les données sont correctes en base, mais l'UI affiche encore l'ancienne version mise en cache de la page.

Conclusion

Les Server Actions ne sont pas « un fetch avec une syntaxe plus jolie » – ce sont deux choses différentes construites sur le même mécanisme. Premièrement, elles s'appuient sur la soumission native des formulaires par le navigateur, donc le formulaire fonctionne même avant que le JavaScript ait eu le temps de se charger – vous n'avez pas à écrire la moindre ligne de code pour ce comportement, il vous est offert par l'architecture même de <form action={...}>. Deuxièmement, et c'est plus important du point de vue de la sécurité : chaque fonction marquée "use server" devient un véritable point d'entrée réseau, appelable de l'extérieur, indépendamment du fait qu'un élément visible de l'UI l'invoque ou non. La traiter comme une fonction locale et de confiance – sans validation ni autorisation à l'intérieur – est le moyen le plus simple de transformer une syntaxe pratique en un endpoint API non sécurisé.