ITSkillsCenter
Développement Web

Formulaires SvelteKit 2 avec form actions et Zod : tutoriel 2026

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

📍 Article principal du cluster : Svelte 5 et SvelteKit 2 en production : guide complet 2026
Tutoriel satellite — pour la vue d’ensemble du cluster, lire d’abord le pilier.

Introduction

Une boutique en ligne de Bamako reçoit chaque jour 200 commandes via un formulaire SvelteKit. Trois fois par semaine, un client perd sa saisie parce que la 4G coupe au moment de valider. La solution n’est pas de mettre plus de JavaScript : c’est de revenir au formulaire HTML standard côté navigateur et de coupler avec les form actions de SvelteKit côté serveur. Ce tutoriel construit pas-à-pas un formulaire d’inscription robuste avec validation Zod partagée serveur et client, progressive enhancement, et gestion d’erreurs lisible. À la fin, le formulaire fonctionne sans JavaScript activé, et reste rapide quand le bundle est chargé.

Prérequis

  • Projet SvelteKit 2 fonctionnel (Svelte 5, TypeScript)
  • Node 22 LTS
  • Connaissance basique des form actions (sinon lire la doc officielle)
  • Niveau : intermédiaire — Temps : 1 h 15

Étape 1 — Installation et structure

npm i zod
mkdir -p src/lib/schemas src/routes/inscription

Trois fichiers à créer :

  • src/lib/schemas/inscription.ts — schéma Zod partagé
  • src/routes/inscription/+page.server.ts — form action serveur
  • src/routes/inscription/+page.svelte — formulaire et progressive enhancement

Étape 2 — Schéma Zod partagé serveur/client

// src/lib/schemas/inscription.ts
import { z } from 'zod';

export const inscriptionSchema = z.object({
  nom: z.string().min(2, 'Le nom doit faire au moins 2 caractères').max(80),
  email: z.string().email('Email invalide'),
  telephone: z.string()
    .regex(/^\+?[0-9\s-]{8,15}$/, 'Format : +221 77 123 45 67 ou 77 123 45 67'),
  pays: z.enum(['SN','CI','ML','BF','GN','TG','BJ','NE'], { message: 'Pays non supporté' }),
  motDePasse: z.string().min(10, 'Au moins 10 caractères').regex(/[A-Z]/, 'Majuscule requise').regex(/[0-9]/, 'Chiffre requis'),
  cgu: z.literal('on', { message: 'Vous devez accepter les CGU' })
});

export type Inscription = z.infer<typeof inscriptionSchema>;

Ce schéma est utilisé deux fois : côté serveur pour la validation faisant autorité, et côté client pour un retour visuel instantané. Cette duplication contrôlée évite la divergence des règles entre les deux contextes — un classique des bugs en production que nous avons rencontré sur des projets fintech à Dakar.

Étape 3 — Form action côté serveur

// src/routes/inscription/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import { inscriptionSchema } from '$lib/schemas/inscription';
import { creerCompte } from '$lib/server/comptes';
import type { Actions } from './$types';

export const actions: Actions = {
  default: async ({ request, cookies, getClientAddress }) => {
    const form = await request.formData();
    const data = Object.fromEntries(form);

    const parsed = inscriptionSchema.safeParse(data);
    if (!parsed.success) {
      const errors = parsed.error.flatten().fieldErrors;
      return fail(400, {
        donnees: { nom: data.nom, email: data.email, telephone: data.telephone, pays: data.pays },
        erreurs: errors
      });
    }

    try {
      const compte = await creerCompte({ ...parsed.data, ip: getClientAddress() });
      cookies.set('session', compte.token, { path: '/', httpOnly: true, secure: true, sameSite: 'lax', maxAge: 60*60*24*30 });
    } catch (e) {
      return fail(500, { donnees: parsed.data, erreurs: { _form: ['Erreur serveur, réessayez'] } });
    }

    redirect(303, '/dashboard');
  }
};

Trois bonnes pratiques visibles dans ce code : (1) on ne renvoie jamais le mot de passe en cas d’erreur — le formulaire le redemande à l’utilisateur ; (2) le cookie de session est httpOnly et secure ; (3) le redirect 303 force la redirection après POST, évitant la double soumission au F5.

Étape 4 — Formulaire avec progressive enhancement

<!-- src/routes/inscription/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import { inscriptionSchema } from '$lib/schemas/inscription';

  let { form } = $props();
  let saisie = $state(form?.donnees ?? { nom: '', email: '', telephone: '', pays: 'SN' });
  let erreursClient = $state<Record<string, string[]>>({});
  let envoi = $state(false);

  function validerClient() {
    const r = inscriptionSchema.safeParse(saisie);
    erreursClient = r.success ? {} : r.error.flatten().fieldErrors;
    return r.success;
  }
</script>

<form method="POST" use:enhance={() => {
  envoi = true;
  return async ({ result, update }) => { envoi = false; await update(); };
}} novalidate>
  <label>Nom complet
    <input name="nom" bind:value={saisie.nom} required />
    {#if erreursClient.nom || form?.erreurs?.nom}
      <span class="erreur">{(erreursClient.nom || form.erreurs.nom)[0]}</span>
    {/if}
  </label>

  <label>Email
    <input name="email" type="email" bind:value={saisie.email} required />
  </label>

  <label>Téléphone (avec indicatif)
    <input name="telephone" bind:value={saisie.telephone} placeholder="+221 77 ..." required />
  </label>

  <label>Pays
    <select name="pays" bind:value={saisie.pays}>
      <option value="SN">Sénégal</option>
      <option value="CI">Côte d'Ivoire</option>
      <option value="ML">Mali</option>
      <option value="BF">Burkina Faso</option>
    </select>
  </label>

  <label>Mot de passe
    <input name="motDePasse" type="password" minlength="10" required />
  </label>

  <label><input type="checkbox" name="cgu" value="on" required /> J'accepte les CGU</label>

  <button type="submit" disabled={envoi}>{envoi ? 'Envoi...' : 'Créer mon compte'}</button>
</form>

L’attribut novalidate désactive la validation HTML5 native (parfois trop agressive) au profit de la nôtre, qui s’aligne sur le schéma Zod. Sans JavaScript activé, le formulaire fonctionne en POST classique — le serveur valide, retourne les erreurs via fail(), SvelteKit ré-affiche la page avec les données. Avec JavaScript, use:enhance intercepte la soumission, l’envoie en fetch, met à jour la page sans rechargement, et désactive le bouton pendant l’envoi.

Étape 5 — Affichage des erreurs côté serveur

Le composant relit form?.erreurs à chaque retour serveur et le superpose aux erreurs client. Si le serveur dit « email déjà utilisé » et que le client n’avait rien détecté, l’utilisateur voit le bon message. Si les deux ont la même contrainte (Zod), c’est l’erreur client qui s’affiche en premier (réactive immédiate).

Étape 6 — Pattern paiement Wave / Orange Money

Pour un formulaire de paiement, on enchaîne deux form actions : ?/initier qui appelle l’API Wave et redirige vers l’URL de paiement, ?/callback que Wave appelle après confirmation. Le secret API reste côté serveur, jamais exposé au client.

// +page.server.ts
import { WAVE_API_KEY } from '$env/static/private';
export const actions = {
  initier: async ({ request }) => {
    const f = await request.formData();
    const r = await fetch('https://api.wave.com/v1/checkout/sessions', {
      method:'POST',
      headers:{'Authorization':`Bearer ${WAVE_API_KEY}`,'Content-Type':'application/json'},
      body: JSON.stringify({ amount: Number(f.get('montant')), currency:'XOF', success_url: '...', error_url: '...' })
    });
    const session = await r.json();
    redirect(303, session.wave_launch_url);
  }
};

Étape 7 — Vérification

Trois tests manuels obligatoires avant déploiement :

  1. Désactiver JavaScript dans Chrome DevTools, soumettre le formulaire avec une erreur volontaire — vérifier que le serveur renvoie la page avec les bonnes erreurs préservées dans les champs.
  2. Réactiver JS, soumettre — vérifier que la page ne se recharge pas et que le bouton se désactive pendant l’envoi.
  3. Soumettre avec un mot de passe valide côté Zod mais déjà utilisé côté base — vérifier que l’erreur métier remonte correctement.

Erreurs fréquentes

Erreur Cause Solution
Form action retourne 405 Pas de method="POST" sur le formulaire Ajouter l’attribut
Données perdues après erreur fail() n’inclut pas les valeurs saisies Renvoyer donnees: {...} dans fail()
Validation client ne se déclenche pas Schema Zod importé depuis $lib/server/... Mettre dans $lib/schemas/... sans dépendance serveur
Cookie session pas envoyé secure: true en localhost http Conditionner sur process.env.NODE_ENV === 'production'
Bouton désactivé même après réponse Pas d’await update() dans enhance Toujours return async ({update}) => { await update(); }

Adaptation au contexte ouest-africain

Le progressive enhancement est rarement un luxe ici : il est nécessaire. Les terminaux Tecno et Itel d’entrée de gamme rament dès qu’un bundle dépasse 200 Ko. La 4G qui tombe à 100 kb/s en heure de pointe sur le Plateau ou à Treichville fait timeout les fetch de 5 secondes par défaut. Un formulaire qui marche sans JavaScript, c’est un formulaire qui marche partout — y compris sur un Nokia 3 hérité d’un cousin. Côté hébergement, la combinaison Hetzner FRA1 + Cloudflare Free devant assure que le HTML initial arrive en moins de 200 ms à Dakar même quand le PoP local de Cloudflare est saturé.

Tutoriels frères

Pour aller plus loin

FAQ

Faut-il préférer SvelteKit Superforms ?
Superforms est une lib qui automatise le pattern de cet article. Très utile pour les gros projets. Pour comprendre les form actions, mieux vaut commencer manuel comme ici, puis adopter Superforms quand on connaît les principes.

Comment gérer les uploads de fichiers ?
Le formulaire reçoit naturellement les FormData avec fichiers. Côté action, form.get('photo') retourne un File. Pour le stockage, voir notre tutoriel Hetzner Object Storage.

Et la protection CSRF ?
SvelteKit l’active par défaut via le header Origin. Aucune action manuelle nécessaire pour les form actions standard.

Plusieurs formulaires sur la même page ?
Utiliser des actions nommées (?/inscrire, ?/connexion) et déclarer chacune dans l’objet actions.

Pattern Superforms : la version industrialisée

Quand le projet grossit (plus de 10 formulaires distincts, validation conditionnelle complexe, internationalisation des messages), écrire chaque action et chaque fail() à la main devient répétitif. Superforms (superforms.rocks) industrialise le pattern de cet article. Installation et migration prennent une demi-journée pour un projet existant.

npm i sveltekit-superforms zod
// +page.server.ts avec Superforms
import { superValidate, message } from 'sveltekit-superforms/server';
import { zod } from 'sveltekit-superforms/adapters';
import { inscriptionSchema } from '$lib/schemas/inscription';

export const load = async () => {
  const form = await superValidate(zod(inscriptionSchema));
  return { form };
};

export const actions = {
  default: async ({ request }) => {
    const form = await superValidate(request, zod(inscriptionSchema));
    if (!form.valid) return fail(400, { form });
    await creerCompte(form.data);
    return message(form, 'Compte créé avec succès');
  }
};

Côté composant, Superforms gère automatiquement la liaison bind:value, l’affichage des erreurs par champ, l’état de soumission, et le mode tainted (champs modifiés). Le code passe de 80 lignes pour un formulaire à 25.

Upload de fichiers et photo de profil

Pour un formulaire avec upload d’avatar (cas typique : profil utilisateur d’une app de coursiers à Abidjan), on combine multipart/form-data, validation côté serveur de la taille et du type MIME, et stockage S3-compatible (Hetzner Object Storage, MinIO auto-hébergé, Wasabi).

<form method="POST" enctype="multipart/form-data" use:enhance>
  <input type="file" name="avatar" accept="image/jpeg,image/png,image/webp" />
  <button type="submit">Envoyer</button>
</form>
// +page.server.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({ region:'fsn1', endpoint:'https://fsn1.your-objectstorage.com', credentials:{ accessKeyId: ACC, secretAccessKey: SEC } });

export const actions = {
  default: async ({ request, locals }) => {
    const f = await request.formData();
    const file = f.get('avatar') as File;
    if (!file || file.size > 2_000_000) return fail(400, { erreur: 'Fichier > 2 Mo' });
    if (!['image/jpeg','image/png','image/webp'].includes(file.type)) return fail(400, { erreur: 'Format invalide' });
    const buf = Buffer.from(await file.arrayBuffer());
    const key = `avatars/${locals.user.id}-${Date.now()}.${file.type.split('/')[1]}`;
    await s3.send(new PutObjectCommand({ Bucket:'mon-bucket', Key:key, Body:buf, ContentType:file.type }));
    return { url: `https://mon-bucket.fsn1.your-objectstorage.com/${key}` };
  }
};

Trois précautions de sécurité : (1) la taille est validée serveur même si l’input HTML5 a un attribut maxlength ; (2) le type MIME est revérifié serveur (le client peut mentir) — pour une vérif à toute épreuve, on lit les magic bytes avec file-type ; (3) le nom de fichier final est généré côté serveur, jamais celui fourni par l’utilisateur.

Formulaires multi-étapes

Pour un onboarding en 3-4 écrans (cas fintech UEMOA : identité, justificatifs, accord conditions), deux approches coexistent. La plus robuste : un seul formulaire avec navigation côté client via $state sur l’étape courante, soumission finale unique. Plus simple et pas de risque de perte intermédiaire. La seconde : une route par étape avec session serveur qui agrège — utile quand chaque étape demande une validation lourde côté serveur (ex : OCR de pièce d’identité).

<script>
  let etape = $state(1);
  let donnees = $state({ nom:'', email:'', telephone:'', adresse:'', cgu:false });
  let valide = $derived(etape === 1
    ? donnees.nom.length > 1 && donnees.email.includes('@')
    : etape === 2 ? donnees.telephone.length > 7 : donnees.cgu);
</script>
{#if etape === 1}...{:else if etape === 2}...{:else}...{/if}

Internationalisation des messages d’erreur

Pour un produit déployé en plusieurs pays UEMOA, les messages doivent être traduits. Zod accepte une fonction d’erreur globale qu’on configure une fois selon la locale détectée. Pour un produit Sénégal/Côte d’Ivoire en français standard, c’est généralement suffisant. Pour ajouter le wolof ou le bambara plus tard, on intègre paraglide-js qui s’intègre nativement à SvelteKit 2.

Sécurité : rate limiting, honeypot, captcha

Trois protections à activer dès le premier formulaire public en production. Le rate limiting empêche un bot de tenter 10 000 inscriptions par minute — implémentation simple via @hono/rate-limiter dans un hook SvelteKit, ou directement dans Caddy avec son module rate_limit. La règle classique : 5 soumissions par IP toutes les 10 minutes pour les formulaires d’inscription, 30/h pour les formulaires de contact.

Le honeypot reste la défense anti-bot la plus discrète : un champ caché en CSS que les bots remplissent et que le serveur rejette silencieusement quand il est non-vide. Coût : 3 lignes de code, taux d’efficacité contre le spam classique 80-90 %.

<label style="position:absolute;left:-9999px" aria-hidden="true">
  Ne pas remplir <input name="website_url" tabindex="-1" />
</label>
// action serveur
if (form.get('website_url')) {
  // bot détecté
  await new Promise(r => setTimeout(r, 2000)); // ralentir
  return fail(400, { erreurs:{ _form:['Erreur'] }});
}

Pour les formulaires à fort enjeu (paiement, création de compte fintech), Cloudflare Turnstile remplace avantageusement reCAPTCHA — gratuit, sans tracking, intégration en cinq lignes côté SvelteKit. La clé site se met dans le composant, la clé secrète dans $env/static/private, et la vérification se fait dans l’action serveur via un appel à https://challenges.cloudflare.com/turnstile/v0/siteverify.

Journalisation des soumissions

Pour le diagnostic et la conformité (RGPD-like UEMOA en cours d’élaboration), on garde une trace des soumissions sensibles : qui, quand, depuis quelle IP, quel résultat. La table form_logs en PostgreSQL ou SQLite suffit. On y stocke le nom du formulaire, l’id utilisateur si connu, l’IP, l’user-agent, le statut (succès/erreur), et un hash anonymisé de l’email pour permettre les recherches sans stocker les emails en clair.

Trois bonnes pratiques : ne jamais logger les mots de passe ni les numéros de carte, même chiffrés ; pruner les logs au-delà de 90 jours sauf obligation légale ; donner accès en lecture aux seuls administrateurs avec audit log secondaire.

Besoin d'un site web ?

Confiez-nous la Création de Votre Site Web

Site vitrine, e-commerce ou application web — nous transformons votre vision en réalité digitale. Accompagnement personnalisé de A à Z.

À partir de 250.000 FCFA
Parlons de Votre Projet
Publicité