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.
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.
- Le lecteur envoie le formulaire du pied de page. use:enhance l'envoie en arrière-plan à l'action subscribe de /newsletter.
- L'action vérifie la case de consentement, puis compte les tentatives récentes depuis l'IP du lecteur.
- Au-delà de la limite, elle renvoie fail(429) et rien n'est envoyé.
- Sinon, elle envoie l'inscription à Ikiform avec la clé secrète et l'Origin du navigateur.
- 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.
Mise en place du projet
src/
env.ts
lib/
NewsletterForm.svelte
server/limit.ts
routes/
+layout.svelte
newsletter/
+page.server.ts
+page.svelte
api/ikiform/+server.ts
.envSvelteKit 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.
import { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
IKIFORM_SECRET_KEY: {},
IKIFORM_WEBHOOK_SECRET: {},
KIT_API_KEY: {},
KIT_FORM_ID: {},
});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=1234567Cré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.
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
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 danspage.form. fail()conserve l'e-mail saisi. SvelteKit 3 transmet aussi le statut defail()pour les formulaires améliorés, donc l'onglet réseau affiche422ou429.- La ligne
Origintransmet l'origine du navigateur, car c'est grâce à cet en-tête qu'Ikiform reconnaît les tests gratuits depuislocalhost, et lefetchd'un serveur n'en envoie aucun. prerender = falsecompte si votre layout racine prérend tout : les pages avec des actions ne peuvent pas être prérendues.
IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.Le composant formulaire
<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: falsegarde le lecteur sur l'article.refreshAll: falseévite de réexécuter chaque fonctionloadaprès une inscription réussie, qui ne change rien à ce qu'elles chargent.reset: falseconserve 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, etpage.formy transporte le résultat.
La page /newsletter
<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.
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é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
- 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. - Désactivez JavaScript et abonnez-vous à nouveau. Vous arrivez sur
/newsletteravec la même ligne. - Envoyez quatre fois en dix minutes. Le quatrième envoi affiche le message de limite, et l'onglet réseau affiche
429. - Ouvrez Submissions dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés.
- Si vous avez ajouté un webhook, ouvrez l'endpoint dans Webhooks. La livraison affiche
succeededavec la réponse de votre serveur. - 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
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.
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.
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.
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.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.