# Créer un formulaire d'inscription à une newsletter avec SvelteKit

> Un formulaire de newsletter SvelteKit 3 dans le layout : une form action avec consentement et limite par IP, un use:enhance qui reste sur la page, et une route de webhook pour Kit.

Publié le 2026-10-25 · https://ikiform.com/fr/guides/newsletter-signup/sveltekit

Placez la form action sur une page `/newsletter` et envoyez-y les données depuis un formulaire du layout racine. `use:enhance` avec `update({ navigate: false })` applique le résultat à la page où se trouve le lecteur, donc le pied de page l'affiche sans quitter l'article, et sans JavaScript le même envoi arrive sur `/newsletter`. L'action vérifie le consentement, limite chaque IP avec `getClientAddress()`, transmet l'inscription à votre backend de formulaires, et une route `+server.ts` passe les inscriptions vérifiées à Kit.

L'[onglet SvelteKit du guide principal](https://ikiform.com/fr/guides/newsletter-signup.md#form) envoie depuis un pied de page vers une action `/newsletter` et traite le résultat à la main. Cette page le construit à la manière de SvelteKit 3 : variables d'environnement explicites, `update({ navigate: false })` pour que le framework applique le résultat, une limite par IP et une route de webhook pour Kit. La configuration du produit se trouve dans le [guide SvelteKit de la documentation](https://ikiform.com/fr/docs/guides/sveltekit.md).

## Ce qu'un formulaire de layout change dans SvelteKit

Les form actions ne vivent que dans un `+page.server.ts`, jamais dans un layout, donc un formulaire de pied de page envoie à l'action d'une autre page. SvelteKit 3 a aligné ici les formulaires améliorés sur les formulaires natifs : un formulaire dont l'action est sur une autre page navigue vers cette page. Les widgets de newsletter sont le cas que la documentation cite pour s'en affranchir, avec `update({ navigate: false })`. Le résultat est alors appliqué à la page courante sous `page.form`, que lit le pied de page.

Schéma : Comment une inscription depuis le pied de page SvelteKit revient sur la page du lecteur

1. Le lecteur envoie le formulaire du pied de page. use:enhance l'envoie en arrière-plan à l'action subscribe de /newsletter.
2. L'action vérifie la case de consentement, puis compte les tentatives récentes depuis l'IP du lecteur.
3. Au-delà de la limite, elle renvoie fail(429) et rien n'est envoyé.
4. Sinon, elle envoie l'inscription à Ikiform avec la clé secrète et l'Origin du navigateur.
5. update avec navigate à false applique le résultat à la page courante sous page.form, et le pied de page l'affiche. Sans JavaScript, le navigateur va sur /newsletter, qui rend le même composant.

Seul le formulaire dont la source correspond au résultat réagit, donc un formulaire de pied de page et un formulaire de page ne changent pas tous les deux.

## Mise en place du projet

`Arborescence du projet`:

```text
src/
  env.ts
  lib/
    NewsletterForm.svelte
    server/limit.ts
  routes/
    +layout.svelte
    newsletter/
      +page.server.ts
      +page.svelte
    api/ikiform/+server.ts
.env
```

SvelteKit 3 déprécie les modules `$env/*` au profit de variables explicites. Déclarez-les dans `src/env.ts` ; elles sont privées et lues à l'exécution par défaut, et `$app/env/private` refuse d'être importé dans du code navigateur.

`src/env.ts`:

```ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
  IKIFORM_WEBHOOK_SECRET: {},
  KIT_API_KEY: {},
  KIT_FORM_ID: {},
});
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
KIT_API_KEY=your_kit_v4_api_key
KIT_FORM_ID=1234567
```

Créez la clé Ikiform sous **API keys** avec le scope `submissions:write` et collez la [définition des champs du guide principal](https://ikiform.com/fr/guides/newsletter-signup.md#fields) dans l'onglet **Fields** du formulaire.

## La construire

### Une limite par IP

Une personne s'abonne une fois. Trois tentatives en dix minutes laissent la place à une faute de frappe et arrêtent un script. `getClientAddress()` renvoie l'IP déterminée par votre adaptateur, donc le limiteur n'a pas besoin de savoir quel en-tête utilise votre hébergeur.

`src/lib/server/limit.ts`:

```ts
const WINDOW_MS = 10 * 60_000;
const LIMIT = 3;
const recent = new Map<string, number[]>();

export function allow(address: string, now = Date.now()) {
  const hits = (recent.get(address) ?? []).filter((time) => now - time < WINDOW_MS);
  if (hits.length >= LIMIT) return false;
  if (recent.size > 10_000) recent.clear();
  recent.set(address, [...hits, now]);
  return true;
}
```

Le compteur vit dans la mémoire d'un seul serveur. Sur les hébergeurs serverless, chaque instance compte de son côté, donc considérez-le comme un ralentisseur et déplacez-le vers un stockage partagé quand le trafic augmente. Les fichiers d'un répertoire `server` ne peuvent pas être importés par du code navigateur. SvelteKit 3 accède à `src/lib` via l'alias `#lib` qu'un nouveau projet déclare sous `imports` dans `package.json` ; l'ancien alias `$lib` a disparu.

### L'action du formulaire

`src/routes/newsletter/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import { allow } from "#lib/server/limit.js";
import type { Actions } from "./$types";

export const prerender = false;

const PLACEMENTS = ["footer", "post", "newsletter-page"];

export const actions = {
  subscribe: async ({ request, fetch, getClientAddress, url }) => {
    const form = await request.formData();
    const email = String(form.get("email") ?? "").trim();
    const picked = String(form.get("source") ?? "");
    const source = PLACEMENTS.includes(picked) ? picked : "footer";
    const refuse = (status: number, field: string, message: string) =>
      fail(status, { newsletter: { ok: false, source, field, message, email } });

    if (form.get("consent") !== "true") return refuse(422, "consent", "Tick the box so we know you want the emails.");
    if (!allow(getClientAddress())) return refuse(429, "form", "Too many tries from this connection. Wait a few minutes.");

    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: JSON.stringify({
        email,
        first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
        consent: true,
        source,
        _gotcha: String(form.get("_gotcha") ?? ""),
      }),
    }).catch(() => null);

    const result = await response?.json().catch(() => null);
    if (result?.ok) return { newsletter: { ok: true, source } };
    const emailError = result?.error?.fields?.email?.[0];
    if (emailError) return refuse(422, "email", emailError);
    return refuse(response?.status ?? 502, "form", result?.error?.message ?? "We couldn't reach the list. Try again.");
  },
} satisfies Actions;
```

- Le résultat est enveloppé dans `newsletter`, donc il n'entre jamais en collision avec le résultat d'un autre formulaire dans `page.form`.
- `fail()` conserve l'e-mail saisi. SvelteKit 3 transmet aussi le statut de `fail()` pour les formulaires améliorés, donc l'onglet réseau affiche `422` ou `429`.
- La ligne `Origin` transmet l'origine du navigateur, car c'est grâce à cet en-tête qu'Ikiform reconnaît les tests gratuits depuis `localhost`, et le `fetch` d'un serveur n'en envoie aucun.
- `prerender = false` compte si votre layout racine prérend tout : les pages avec des actions ne peuvent pas être prérendues.

> Les versions côté serveur envoient avec une clé secrète, donc Ikiform ignore **Allowed domains** et sa vérification humaine. Chaque envoi vient alors de l'IP de votre serveur, et la limite Standard de 5 par minute et par visiteur compte tous vos visiteurs ensemble. Choisissez la [limite de débit](https://ikiform.com/fr/docs/rate-limits.md) Relaxed ou Custom pour un formulaire que vous envoyez via un serveur, et gardez `IKIFORM_SECRET_KEY` dans des variables d'environnement réservées au serveur.

### Le composant formulaire

`src/lib/NewsletterForm.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import { page } from "$app/state";

  type Outcome = { ok: boolean; source: string; field?: string; message?: string; email?: string };

  let { source = "footer" }: { source?: "footer" | "post" | "newsletter-page" } = $props();
  let pending = $state(false);
  const outcome = $derived.by(() => {
    const value = page.form?.newsletter as Outcome | undefined;
    return value?.source === source ? value : undefined;
  });
</script>

{#if outcome?.ok}
  <p role="status">Almost done. Open the email we sent you and confirm your address.</p>
{:else}
  <form
    method="POST"
    action="/newsletter?/subscribe"
    use:enhance={() => {
      pending = true;
      return async ({ update }) => {
        await update({ navigate: false, refreshAll: false, reset: false });
        pending = false;
      };
    }}
  >
    <label>
      Email
      <input name="email" type="email" required autocomplete="email" value={outcome?.email ?? ""} aria-invalid={outcome?.field === "email"} />
    </label>
    <label>
      First name (optional)
      <input name="first_name" maxlength="80" autocomplete="given-name" />
    </label>
    <label>
      <input name="consent" type="checkbox" value="true" required aria-invalid={outcome?.field === "consent"} />
      Email me the newsletter. I can unsubscribe at any time.
    </label>
    <input type="hidden" name="source" value={source} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if outcome?.message}
      <p role="alert">{outcome.message}</p>
    {/if}
    <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
  </form>
{/if}
```

- `navigate: false` garde le lecteur sur l'article. `refreshAll: false` évite de réexécuter chaque fonction `load` après une inscription réussie, qui ne change rien à ce qu'elles chargent.
- `reset: false` conserve l'e-mail saisi en cas d'erreur. En cas de succès, le formulaire est de toute façon remplacé.
- Avec JavaScript désactivé, l'envoi va sur `/newsletter`, qui rend ce même composant, et `page.form` y transporte le résultat.

### La page /newsletter

`src/routes/newsletter/+page.svelte`:

```svelte
<script lang="ts">
  import NewsletterForm from "#lib/NewsletterForm.svelte";
</script>

<svelte:head>
  <title>Newsletter</title>
</svelte:head>

<h1>The monthly newsletter</h1>
<p>What we shipped, what broke and what we learned. One email a month.</p>
<NewsletterForm source="newsletter-page" />
```

Affichez `<NewsletterForm />` dans `+layout.svelte` pour le pied de page, et passez `source="post"` à la fin des articles.

## Transmettre les abonnés à Kit

Cette route `+server.ts` reçoit le webhook signé d'Ikiform, crée l'abonné dans Kit comme inactif et l'ajoute à un formulaire Kit, dont l'e-mail d'incitation sert de confirmation Kit ; testez-la une fois avec une adresse qui vous appartient. Elle lit d'abord le corps comme texte, car la signature couvre les octets exacts, et renvoie de simples objets `Response`, puisque SvelteKit 3 déprécie les helpers `json()` et `text()`. Le [guide Kit](https://ikiform.com/fr/guides/newsletter-signup/kit.md) explique chaque réponse de Kit et ajoute des tags.

`src/routes/api/ikiform/+server.ts`:

```ts
import { IKIFORM_WEBHOOK_SECRET, KIT_API_KEY, KIT_FORM_ID } from "$app/env/private";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import type { RequestHandler } from "./$types";

export const POST: RequestHandler = async ({ request, fetch }) => {
  let event;
  try {
    event = await verifyWebhook({ payload: await request.text(), headers: request.headers, secret: IKIFORM_WEBHOOK_SECRET });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
  if (event.type !== "submission.created") return new Response(null, { status: 204 });

  const { email, first_name, consent } = event.data.fields;
  if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });

  const kit = (path: string, body: Record<string, unknown>) =>
    fetch(`https://api.kit.com/v4${path}`, {
      method: "POST",
      headers: { "X-Kit-Api-Key": KIT_API_KEY, "Content-Type": "application/json", Accept: "application/json" },
      body: JSON.stringify(body),
    });

  const created = await kit("/subscribers", {
    email_address: email,
    first_name: typeof first_name === "string" ? first_name : null,
    state: "inactive",
  });
  const added = created.ok ? await kit(`/forms/${KIT_FORM_ID}/subscribers`, { email_address: email }) : created;

  if (added.status === 429) return new Response("Kit rate limit", { status: 503, headers: { "retry-after": "60" } });
  if (!added.ok) return new Response(`Kit answered ${added.status}`, { status: 502 });
  return new Response(null, { status: 204 });
};
```

La protection CSRF de SvelteKit est toujours active en version 3, mais elle ne rejette que les envois de formulaire cross-origin et les POST cross-origin sans `Content-Type`. Ikiform envoie `application/json`, donc le webhook passe. Ajoutez l'URL sous **Webhooks** et copiez le secret `whsec_`.

## Honeypot et vérification humaine

L'input `_gotcha` est transmis et Ikiform enregistre un champ rempli comme spam, avec une réponse de succès et sans webhook, donc Kit ne le voit jamais. La clé secrète contourne la vérification Turnstile d'Ikiform ; la limite par IP ci-dessus couvre la boucle qu'exécuterait un script. Pour les bots qui utilisent de nombreuses IP, vérifiez un jeton Turnstile dans l'action, comme le montre le [guide Turnstile](https://ikiform.com/fr/guides/spam-protection/cloudflare-turnstile.md).

## Déployer

| Hébergeur | Que faire |
| --- | --- |
| Vercel | `adapter-vercel` exécute les actions comme des fonctions. SvelteKit 3 y abandonne le runtime edge, donc gardez celui par défaut. Définissez les variables sous **Environment Variables**. |
| Netlify | `adapter-netlify` suit la Frameworks API de Netlify, et le répertoire de publication est désormais une option de l'adaptateur. Définissez les variables dans les paramètres d'environnement du site. |
| Cloudflare | `adapter-cloudflare` compile pour Workers et nécessite Wrangler 4.118 ou plus récent, sa dépendance pair. Sa documentation recommande `$app/env` pour les variables. Utilisez un domaine personnalisé pour le webhook ; Ikiform refuse `workers.dev` et `pages.dev`. |

## Tester gratuitement sur localhost

Un espace de travail sans offre accepte 50 soumissions de test par mois depuis `localhost`, et l'en-tête `Origin` transmis fait de chaque inscription depuis le serveur de développement l'une d'elles. Le limiteur vous compte aussi, donc redémarrez le serveur de développement si vous l'atteignez pendant vos tests. Ikiform ne livre pas de webhooks à `localhost` ; exposez le serveur de développement via un tunnel avec une URL HTTPS pour tester la route.

## Vérifiez que tout fonctionne

1. Lancez `vite dev`, ouvrez n'importe quelle page et abonnez-vous depuis le pied de page. La ligne apparaît dans le pied de page et l'URL ne change pas.
2. Désactivez JavaScript et abonnez-vous à nouveau. Vous arrivez sur `/newsletter` avec la même ligne.
3. Envoyez quatre fois en dix minutes. Le quatrième envoi affiche le message de limite, et l'onglet réseau affiche `429`.
4. Ouvrez **Submissions** dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés.
5. Si vous avez ajouté un webhook, ouvrez l'endpoint dans **Webhooks**. La livraison affiche `succeeded` avec la réponse de votre serveur.
6. Ouvrez Kit. L'abonné y est en inactive, et l'e-mail de confirmation de Kit est dans votre boîte.

## Pièges

| Problème | Que faire |
| --- | --- |
| L'envoi du formulaire du pied de page saute vers `/newsletter` même avec JavaScript | Le callback de `use:enhance` appelle `update()` sans `navigate: false`. Dans SvelteKit 3, les actions entre pages naviguent par défaut. |
| Le build échoue parce que `/newsletter` ne peut pas être prérendue | Un layout définit `prerender = true`. Les pages avec des actions ont besoin d'un serveur ; définissez `prerender = false` dans le fichier serveur de la page. |
| Le formulaire du pied de page et celui de la page affichent tous deux le résultat | Ils lisent le même `page.form`. Comparez sa `source` à celle du composant, comme le fait l'exemple. |
| `402 plan_required` sur les tests locaux | L'envoi n'avait pas d'origine `localhost`. Vérifiez la ligne `Origin`, et ouvrez le site sur `localhost`. |
| La soumission est dans le tableau de bord mais aucun e-mail ni webhook n'est arrivé | Elle a été stockée comme spam. Le spam n'envoie rien. Ouvrez-la sous le filtre **Spam** pour voir les raisons, et marquez-la comme non-spam pour envoyer son e-mail et ses webhooks. |

## Questions

### Comment placer une form action SvelteKit dans le layout ?

Vous ne pouvez pas en définir une à cet endroit ; les actions appartiennent à un fichier +page.server. Placez l'action sur une page comme /newsletter et envoyez-y les données depuis le layout avec action="/newsletter?/subscribe". Utilisez use:enhance avec update({ navigate: false }) pour rester sur la page courante.

### Un site SvelteKit prérendu peut-il avoir un formulaire de newsletter ?

Oui. Prérendez les articles et ne laissez au serveur que la page avec l'action et la route de webhook. Un site sans aucun serveur peut plutôt envoyer directement depuis le navigateur vers Ikiform.

### Pourquoi l'abonné arrive-t-il dans Kit via un webhook ?

Parce que seul le webhook sait qui est réel. La form action reçoit la même réponse de succès pour un bot que pour un lecteur, alors que le webhook ne se déclenche que pour les inscriptions qui passent la vérification anti-spam d'Ikiform, donc Kit ne voit jamais les bots.

### Ikiform est-il gratuit ?

Non, il n'y a pas d'offre gratuite. Les tests sont gratuits : vous pouvez envoyer 50 soumissions de test par mois depuis localhost avant de payer. Pour passer en production, Pro commence à 6 $/mois pour 500 soumissions, ou vous pouvez payer une seule fois 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais.

## Autres guides

- [Formulaire d'inscription à la newsletter](https://ikiform.com/fr/guides/newsletter-signup.md): Créez un formulaire d'inscription à la newsletter avec votre propre markup, enregistrez le consentement, bloquez les inscriptions de bots et transmettez chaque abonné à Kit, MailerLite, beehiiv ou n'importe quel outil.
- [Kit (ConvertKit)](https://ikiform.com/fr/guides/newsletter-signup/kit.md): Reliez votre propre formulaire de newsletter à Kit avec un relais qui crée l'abonné, l'ajoute à un formulaire Kit pour l'incentive email et étiquette l'emplacement.
- [Double opt-in](https://ikiform.com/fr/guides/newsletter-signup/double-opt-in.md): Ikiform n'envoie pas d'e-mails de confirmation : le double opt-in se fait donc dans votre outil d'e-mailing. Un relais ajoute chaque inscription comme non confirmée et l'outil envoie le lien.
- [Formulaire de newsletter Nuxt](https://ikiform.com/fr/guides/newsletter-signup/nuxt.md): Un formulaire de newsletter Nuxt qui fonctionne avec ou sans JavaScript : une route serveur avec vérification Turnstile envoie à Ikiform, et une route Nitro alimente beehiiv.
- [Site statique](https://ikiform.com/fr/guides/newsletter-signup/static-site.md): Ajoutez un formulaire d'inscription à la newsletter à un site statique Hugo, Jekyll, Eleventy ou Astro, gardez la clé d'API de l'outil d'e-mailing hors de la page et confirmez dans l'outil.
- [MailerLite](https://ikiform.com/fr/guides/newsletter-signup/mailerlite.md): Reliez votre propre formulaire d'inscription à MailerLite : un relais ajoute les nouveaux lecteurs comme non confirmés pour le double opt-in, sans jamais demander deux fois à un abonné de confirmer.
