Formulaire d'inscription à la newsletter

Formulaire d'inscription à la newsletter9 min de lecture

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.

Preet SutharFondateur, Ikiform

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 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.

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.

Comment une inscription depuis le pied de page SvelteKit revient sur la page du lecteurformulairedepieddepage+layout.svelte,toutepage?/subscribeactionsur/newslettergetClientAddress()3tentativespar10minutesIkiformclésecrète,stockel'inscriptionpage.formsurlapagecourantefail(429)message,riend'envoyéuse:enhanceautorisénavigate: falseau-delà de la limiteComment une inscription depuis le pied de page SvelteKit revient sur la page du lecteurformulaire depied de page+layout.svelte, toutepage?/subscribeaction sur/newslettergetClientAddress()3 tentativespar 10 minutesIkiformclé secrète, stockel'inscriptionpage.formsur la page courantefail(429)message, riend'envoyéuse:enhanceautorisénavigate: falseau-delà de la limite
  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
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
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
  IKIFORM_WEBHOOK_SECRET: {},
  KIT_API_KEY: {},
  KIT_FORM_ID: {},
});
.env
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 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
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
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 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
<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
<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 explique chaque réponse de Kit et ajoute des tags.

src/routes/api/ikiform/+server.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.

Déployer

HébergeurQue faire
Verceladapter-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.
Netlifyadapter-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.
Cloudflareadapter-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èmeQue faire
L'envoi du formulaire du pied de page saute vers /newsletter même avec JavaScriptLe 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érendueUn 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ésultatIls lisent le même page.form. Comparez sa source à celle du composant, comme le fait l'exemple.
402 plan_required sur les tests locauxL'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

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.