Développement Web

Server Actions dans Next.js 15 : formulaires et mutations sans API

12 دقائق للقراءة

Pendant des années, le pattern par défaut pour gérer un formulaire dans une application React-quelque-chose ressemblait à ceci : un useState par champ, un onSubmit qui empêche le défaut, un fetch('/api/...') vers une API route, une réponse parsée, une mise à jour d’état pour afficher le résultat. Multiplié par dix formulaires, cela faisait plusieurs centaines de lignes de plomberie pour faire ce qu’on voulait vraiment : exécuter du code serveur depuis une interaction client.

Les Server Actions, stabilisées dans Next.js 15, balayent cette plomberie. On écrit une fonction asynchrone marquée "use server", on la passe en action à un <form>, et tout le reste — sérialisation des champs, requête HTTP, validation, retour, invalidation du cache — est géré par le framework. Ce tutoriel construit, étape par étape, un formulaire d’ajout d’avis avec validation, gestion d’erreur, optimistic UI, et invalidation du cache. Le code est minimal mais couvre tous les patterns utilisés en production.

Prérequis

  • Un projet Next.js 15.x avec App Router
  • Comprendre Server vs Client Components
  • Avoir lu le tutoriel sur le data fetching et le caching (les Server Actions interagissent étroitement avec lui)
  • Niveau : intermédiaire
  • Temps estimé : 60 à 80 minutes
  • Optionnel : Zod installé (pnpm add zod) pour la validation typée

Étape 1 — La forme la plus simple

Une Server Action est une fonction asynchrone marquée "use server". Elle peut être déclarée dans un fichier dédié (qui devient automatiquement « server-only ») ou directement à l’intérieur d’un Server Component.

// src/app/produits/[slug]/page.tsx
import { revalidatePath } from 'next/cache';

export default async function PageProduit({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;

  async function ajouterAvis(formData: FormData) {
    'use server';
    const note = Number(formData.get('note'));
    const texte = formData.get('texte') as string;
    // Ici, on appellerait la base : await prisma.avis.create({...})
    console.log('Nouvel avis recu', { slug, note, texte });
    revalidatePath(`/produits/${slug}`);
  }

  return (
    <form action={ajouterAvis} className="space-y-3">
      <input name="note" type="number" min={1} max={5} required
        className="border rounded px-2 py-1" />
      <textarea name="texte" required
        className="border rounded px-2 py-1 w-full" />
      <button type="submit" className="bg-black text-white px-4 py-2 rounded">
        Envoyer
      </button>
    </form>
  );
}

C’est tout. Pas d’API route, pas de useState, pas de fetch côté client. Le formulaire est rendu en HTML côté serveur ; à la soumission, Next.js sérialise les champs en multipart, déclenche la Server Action côté serveur, attend le retour, puis re-rend la page. La directive revalidatePath dit à Next.js d’invalider le cache de cette route pour que le prochain rendu affiche la nouvelle donnée. Bonus crucial : ce formulaire fonctionne même sans JavaScript chargé côté client. C’est du progressive enhancement natif.

Étape 2 — Validation avec Zod

Dans la vraie vie, on ne fait jamais confiance aux données du client. Même si le HTML a min={1} max={5}, n’importe qui peut envoyer une note de 9000 en bypassant le formulaire. La validation se fait côté serveur, dans la Server Action. Zod est l’outil de référence pour cela en TypeScript.

// src/actions/avis.ts
'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';

const schemaAvis = z.object({
  slug: z.string().min(1),
  note: z.coerce.number().int().min(1).max(5),
  texte: z.string().min(10, 'Au moins 10 caracteres').max(2000),
});

export async function ajouterAvis(formData: FormData) {
  const data = schemaAvis.safeParse({
    slug: formData.get('slug'),
    note: formData.get('note'),
    texte: formData.get('texte'),
  });

  if (!data.success) {
    return { ok: false, erreurs: data.error.flatten().fieldErrors };
  }

  // await prisma.avis.create({ data: data.data });
  revalidatePath(`/produits/${data.data.slug}`);
  return { ok: true };
}

On a sorti la Server Action dans un fichier dédié src/actions/avis.ts qui est marqué 'use server' en tête : tout le fichier ne peut être importé que côté serveur (et utilisé comme action côté client). L’avantage : on peut réutiliser la même action depuis plusieurs formulaires. Le retour est un objet sérialisable qui sera transmis au composant client appelant.

Étape 3 — useActionState pour gérer le retour côté client

Pour exploiter le retour de la Server Action (succès ou erreurs), on encapsule le formulaire dans un Client Component qui utilise le hook React 19 useActionState. Ce hook gère automatiquement l’état pending et le résultat de l’action.

// src/components/FormulaireAvis.tsx
'use client';

import { useActionState } from 'react';
import { ajouterAvis } from '@/actions/avis';

const etatInitial = { ok: false as boolean, erreurs: {} as Record<string, string[]> };

export function FormulaireAvis({ slug }: { slug: string }) {
  const [etat, action, isPending] = useActionState(
    async (_prev: typeof etatInitial, formData: FormData) => {
      const res = await ajouterAvis(formData);
      return res.ok ? { ok: true, erreurs: {} } : res;
    },
    etatInitial
  );

  return (
    <form action={action} className="space-y-3">
      <input type="hidden" name="slug" value={slug} />
      <input name="note" type="number" min={1} max={5} required
        className="border rounded px-2 py-1" />
      {etat.erreurs?.note && (
        <p className="text-sm text-red-600">{etat.erreurs.note[0]}</p>
      )}
      <textarea name="texte" required
        className="border rounded px-2 py-1 w-full" />
      {etat.erreurs?.texte && (
        <p className="text-sm text-red-600">{etat.erreurs.texte[0]}</p>
      )}
      <button type="submit" disabled={isPending}
        className="bg-black text-white px-4 py-2 rounded disabled:opacity-50">
        {isPending ? 'Envoi en cours...' : 'Envoyer'}
      </button>
      {etat.ok && <p className="text-sm text-green-600">Merci, avis enregistre.</p>}
    </form>
  );
}

Le hook useActionState retourne trois valeurs : l’état courant (initial puis le retour de l’action), une fonction d’action enveloppée à passer au formulaire, et un booléen isPending. Tout le boilerplate loading + error + success tient en trois variables.

Notez le <input type="hidden" name="slug"> qui passe le slug du produit dans le formulaire. C’est la manière standard de transmettre des données contextuelles à une Server Action sans les exposer dans l’URL.

Étape 4 — Optimistic UI avec useOptimistic

Pour les actions où on veut un feedback instantané sans attendre le retour du serveur (typiquement un like, un toggle, un ajout au panier), React 19 fournit useOptimistic. On annonce visuellement le nouveau résultat avant même que le serveur ait répondu ; si le serveur retourne une erreur, l’UI revient à l’état précédent.

// src/components/Like.tsx
'use client';

import { useOptimistic, useTransition } from 'react';
import { toggleLike } from '@/actions/like';

export function Like({ id, likes, liked }: { id: string; likes: number; liked: boolean }) {
  const [optimistic, setOptimistic] = useOptimistic(
    { likes, liked },
    (state, action: 'toggle') => ({
      likes: state.liked ? state.likes - 1 : state.likes + 1,
      liked: !state.liked,
    })
  );
  const [isPending, startTransition] = useTransition();

  return (
    <button
      onClick={() => startTransition(async () => {
        setOptimistic('toggle');
        await toggleLike(id);
      })}
      disabled={isPending}
      className={optimistic.liked ? 'text-red-600' : 'text-gray-600'}
    >
      {optimistic.liked ? '♥' : '♡'} {optimistic.likes}
    </button>
  );
}

Le clic met à jour l’état optimiste immédiatement (cœur rempli, compteur incrémenté), puis l’action serveur s’exécute. Si elle réussit, le re-rendu serveur confirme l’état. Si elle échoue (rare, mais possible), React revert à l’état précédent.

Étape 5 — Sécurité et protection CSRF

Next.js 15 valide automatiquement l’Origin des requêtes Server Actions et applique une protection CSRF native. On n’a pas besoin d’ajouter manuellement de jeton CSRF — c’est intégré au framework. Cependant, deux pièges restent à la charge du développeur.

Premier piège : l’autorisation. Une Server Action exportée est appelable par n’importe qui qui devine son URL interne. Il faut toujours vérifier les permissions à l’intérieur de l’action.

// src/actions/admin.ts
'use server';

import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';

export async function supprimerProduit(id: string) {
  const session = await auth();
  if (!session || session.role !== 'admin') {
    throw new Error('Non autorise');
  }
  // await prisma.produit.delete({ where: { id } });
  redirect('/admin/produits');
}

Deuxième piège : la validation. Les Server Actions peuvent recevoir n’importe quel type d’argument depuis le client. Toujours valider avec Zod ou un équivalent avant de toucher la base. Le typage TypeScript ne suffit pas — il disparaît à l’exécution.

Étape 6 — Server Actions hors formulaire

Une Server Action peut être appelée sans formulaire, depuis n’importe quel gestionnaire d’événement client. C’est utile pour des boutons d’action simple, des toggles, ou des suppressions.

// src/components/BoutonSupprimer.tsx
'use client';

import { useTransition } from 'react';
import { supprimerCommande } from '@/actions/commandes';

export function BoutonSupprimer({ id }: { id: string }) {
  const [isPending, startTransition] = useTransition();

  return (
    <button
      onClick={() => {
        if (!confirm('Supprimer cette commande ?')) return;
        startTransition(() => supprimerCommande(id));
      }}
      disabled={isPending}
      className="text-red-600 hover:underline"
    >
      {isPending ? 'Suppression...' : 'Supprimer'}
    </button>
  );
}

Le useTransition permet de marquer l’appel comme non-urgent : si l’utilisateur clique pendant que l’action tourne, React garde l’UI réactive. isPending permet de désactiver le bouton pour éviter les doubles-clics. Pour les actions vraiment critiques (paiement), ajouter en plus un idempotency key côté serveur.

Étape 7 — Gestion des erreurs serveur

Si une Server Action lance une exception non gérée, elle remonte jusqu’à l’error.tsx le plus proche dans l’arborescence. Cela peut être trop brutal pour une simple erreur de formulaire. Le pattern recommandé : retourner les erreurs attendues dans l’objet de retour (ce qu’on a fait au Step 2), et lancer une exception uniquement pour les erreurs inattendues (perte de connexion DB, bug de code).

'use server';

export async function payerCommande(id: string) {
  try {
    const session = await auth();
    if (!session) return { ok: false, motif: 'auth' as const };

    const commande = await prisma.commande.findUnique({ where: { id } });
    if (!commande) return { ok: false, motif: 'introuvable' as const };
    if (commande.payee) return { ok: false, motif: 'deja-payee' as const };

    await stripe.charges.create({ ... });
    await prisma.commande.update({ where: { id }, data: { payee: true } });
    return { ok: true };
  } catch (e) {
    // Vraie erreur inattendue (Stripe down, DB down)
    console.error('payerCommande failed', e);
    throw e; // remonte vers error.tsx
  }
}

Côté client, on traite uniquement les cas !ok en affichant un message approprié selon motif. Les exceptions remontent au boundary d’erreur global qui affiche une page d’incident propre.

Erreurs fréquentes

Erreur Cause Solution
« Server Actions must be async functions » Fonction marquée "use server" mais non async Ajouter async à la déclaration
Formulaire qui ne se soumet pas, aucune erreur visible Action passée comme onSubmit au lieu de action Utiliser action={ajouterAvis} sur le <form>
Données du formulaire toutes null côté serveur Les inputs n’ont pas d’attribut name Ajouter name="champ" sur chaque input — c’est ce qui apparaît dans formData.get
Page qui ne se rafraîchit pas après une mutation Oubli de revalidatePath ou revalidateTag Toujours appeler une fonction de revalidation à la fin d’une mutation réussie
« Can’t pass non-serializable values to Server Action » Tentative de passer une fonction ou un objet complexe comme argument Passer uniquement des primitives, FormData ou plain objects
Bouton désactivable qui reste désactivé après l’action useTransition sans cleanup propre, ou exception non catchée Vérifier que l’action retourne ou throw proprement ; envelopper dans try/catch si besoin

Étape 8 — Tester une Server Action

Comme une Server Action est une fonction async normale, on peut la tester comme n’importe quel code TypeScript. La complication tient à ses dépendances : cookies(), auth(), revalidatePath qui ne sont disponibles que dans le contexte d’une requête Next.js. Trois approches pragmatiques.

Tests unitaires avec mocks Vitest. On extrait la logique métier dans une fonction pure indépendante, on teste celle-ci, et la Server Action devient un wrapper mince qui appelle la fonction pure puis fait le revalidatePath. C’est l’approche qui passe à l’échelle.

// src/lib/avis-business.ts (testable indépendamment)
export async function creerAvisLogique(input: {
  slug: string; note: number; texte: string; userId: string;
}) {
  if (input.note < 1 || input.note > 5) throw new Error('Note invalide');
  return prisma.avis.create({ data: input });
}

// src/actions/avis.ts
'use server';
import { creerAvisLogique } from '@/lib/avis-business';
export async function ajouterAvis(formData: FormData) {
  const session = await auth();
  if (!session) throw new Error('Non authentifie');
  const res = await creerAvisLogique({ /* ... */ userId: session.user.id });
  revalidatePath('/produits');
  return { ok: true, id: res.id };
}

Tests end-to-end avec Playwright. Pour vérifier le flux complet (formulaire → soumission → re-rendu → invalidation), on écrit un test Playwright qui pilote le navigateur. C’est lent mais c’est la seule manière d’attraper les bugs d’intégration côté UI + serveur.

Étape 9 — Observabilité en production

Les Server Actions sont des fonctions serverless lorsqu’elles tournent sur Vercel — chacune a sa propre durée d’exécution, son cold start, ses erreurs. Pour les voir clairement, brancher un APM tôt. Sentry, Axiom ou Datadog s’intègrent en quelques lignes dans instrumentation.ts à la racine du projet, et capturent automatiquement les exceptions Server Actions avec leur stack trace.

Bonus utile : logger le couple (userId, action, durée) à la sortie de chaque Server Action critique. Cela permet, après quelques semaines, de repérer les actions lentes qui ralentissent l’UX et d’identifier les utilisateurs qui en abusent (signalement d’API spammée).

Pour aller plus loin

Cet article fait partie d’une série sur React 19 et Next.js 15. Pour la vue d’ensemble et le parcours conseillé, consultez React 19 + Next.js 15 : architecture full-stack moderne.

Tutoriels frères :

Ressources officielles :

Service ITSkillsCenter

Site ou application web sur mesure

Conception Pro + Nom de domaine 1 an + Hébergement 1 an + Formation + Support 6 mois. Accès et code livrés. À partir de 350 000 FCFA.

Demander un devis
Publicité