Newsletter-Anmeldeformular8 Min. Lesezeit
Newsletter-Anmeldeformular mit SvelteKit bauen
Ein Newsletter-Formular mit SvelteKit 3 im Layout: eine Form Action mit Einwilligung und IP-Limit, use:enhance, das auf der Seite bleibt, und eine Webhook-Route für Kit.
Leg die Form Action auf eine Seite /newsletter und sende aus einem Formular im Root-Layout daran. use:enhance mit update({ navigate: false }) wendet das Ergebnis auf die Seite an, auf der der Leser ist, sodass der Footer es zeigt, ohne den Artikel zu verlassen, und ohne JavaScript landet derselbe Post auf /newsletter. Die Action prüft die Einwilligung, begrenzt jede IP mit getClientAddress() und leitet die Anmeldung an dein Formular-Backend weiter, und eine +server.ts-Route gibt verifizierte Anmeldungen an Kit weiter.
Der SvelteKit-Tab der Hauptanleitung sendet aus einem Footer an eine Action /newsletter und verarbeitet das Ergebnis von Hand. Diese Seite baut es auf die Art von SvelteKit 3: explizite Umgebungsvariablen, update({ navigate: false }), damit das Framework das Ergebnis anwendet, ein Limit pro IP und eine Webhook-Route für Kit. Das Produkt-Setup steht in der SvelteKit-Anleitung der Doku.
Was ein Layout-Formular in SvelteKit ändert
Form Actions leben nur in einer +page.server.ts, nie in einem Layout, also sendet ein Footer-Formular an die Action einer anderen Seite. SvelteKit 3 hat enhanced Formulare hier wie native verhalten lassen: Ein Formular, dessen Action auf einer anderen Seite liegt, navigiert zu dieser Seite. Newsletter-Widgets sind genau der Fall, den die Doku für das Abschalten mit update({ navigate: false }) nennt. Das Ergebnis wird dann als page.form auf die aktuelle Seite angewendet, was der Footer liest.
- Der Leser sendet das Footer-Formular ab. use:enhance schickt es im Hintergrund an die subscribe-Action auf /newsletter.
- Die Action prüft die Einwilligungs-Checkbox und zählt dann die letzten Versuche von der IP des Lesers.
- Über dem Limit gibt sie fail(429) zurück, und nichts wird gesendet.
- Sonst sendet sie die Anmeldung mit dem Secret Key und dem Origin des Browsers an Ikiform.
- update mit navigate auf false wendet das Ergebnis als page.form auf die aktuelle Seite an, und der Footer zeigt es. Ohne JavaScript geht der Browser zu /newsletter, das dieselbe Komponente rendert.
Projekt-Setup
src/
env.ts
lib/
NewsletterForm.svelte
server/limit.ts
routes/
+layout.svelte
newsletter/
+page.server.ts
+page.svelte
api/ikiform/+server.ts
.envSvelteKit 3 markiert die $env/*-Module zugunsten expliziter Variablen als veraltet. Deklariere sie in src/env.ts; sie sind standardmäßig privat und werden zur Laufzeit gelesen, und $app/env/private lässt sich nicht in Browser-Code importieren.
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=1234567Erstelle den Ikiform-Key unter API keys mit dem Scope submissions:write und füge die Felddefinition der Hauptanleitung im Tab Fields des Formulars ein.
Bauen
Ein Limit pro IP
Eine Person abonniert einmal. Drei Versuche in zehn Minuten lassen Raum für einen Tippfehler und stoppen ein Skript. getClientAddress() gibt die IP zurück, die dein Adapter ermittelt hat, sodass der Limiter nicht wissen muss, welchen Header dein Host nutzt.
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;
}Der Zähler lebt im Speicher eines Servers. Auf Serverless-Hosts zählt jede Instanz für sich, also betrachte ihn als Bremsschwelle und verlege ihn in einen gemeinsamen Store, wenn der Traffic wächst. Dateien in einem server-Verzeichnis können nicht von Browser-Code importiert werden. SvelteKit 3 erreicht src/lib über den Alias #lib, den ein neues Projekt unter imports in package.json deklariert; der alte Alias $lib ist weg.
Die Action
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;- Das Ergebnis ist in
newsletterverpackt, sodass es nie mit dem Ergebnis eines anderen Formulars inpage.formkollidiert. fail()behält die eingetippte E-Mail. SvelteKit 3 sendet bei enhanced Formularen außerdem den Status ausfail()mit, sodass der Netzwerk-Tab422oder429zeigt.- Die
Origin-Zeile reicht den Origin des Browsers weiter, denn Ikiform erkennt kostenloselocalhost-Tests an diesem Header, und dasfetcheines Servers sendet keinen. prerender = falseist wichtig, wenn dein Root-Layout alles vorrendert: Seiten mit Actions können nicht vorgerendert werden.
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Die Formular-Komponente
<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: falsehält den Leser auf dem Artikel.refreshAll: falseüberspringt nach einer erfolgreichen Anmeldung das erneute Ausführen jederload-Funktion, denn die Anmeldung ändert nichts, was sie laden.reset: falsebehält bei einem Fehler die eingetippte E-Mail. Bei Erfolg wird das Formular ohnehin ersetzt.- Mit ausgeschaltetem JavaScript geht der Post an
/newsletter, das dieselbe Komponente rendert, undpage.formträgt das Ergebnis dorthin.
Die Seite /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" />Rendere <NewsletterForm /> für den Footer in +layout.svelte und übergib am Ende von Artikeln source="post".
Abonnenten an Kit weiterleiten
Diese +server.ts-Route empfängt den signierten Webhook von Ikiform, legt den Abonnenten in Kit als inactive an und fügt ihn einem Kit-Formular hinzu, dessen Incentive-E-Mail die Bestätigung von Kit ist; teste das einmal mit einer Adresse, die dir gehört. Sie liest den Body zuerst als Text, weil die Signatur genau die Bytes abdeckt, und gibt einfache Response-Objekte zurück, da SvelteKit 3 die Helper json() und text() als veraltet markiert. Die Kit-Anleitung erklärt jede Antwort von Kit und ergänzt 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 });
};Der CSRF-Schutz von SvelteKit ist in Version 3 immer aktiv, lehnt aber nur Cross-Origin-Formular-Einsendungen und Cross-Origin-Posts ohne Content-Type ab. Ikiform sendet application/json, also kommt der Webhook durch. Füge die URL unter Webhooks hinzu und kopiere das whsec_-Secret.
Honeypot und Menschlichkeitsprüfung
Der _gotcha-Input wird weitergeleitet, und Ikiform speichert einen ausgefüllten als Spam, mit Erfolgsantwort und ohne Webhook, sodass Kit ihn nie sieht. Der Secret Key überspringt die Turnstile-Prüfung von Ikiform; das IP-Limit oben deckt die Schleife ab, die ein Skript laufen lassen würde. Für Bots mit vielen IPs verifiziere in der Action ein Turnstile-Token, wie die Turnstile-Anleitung zeigt.
Deployen
| Host | Was zu tun ist |
|---|---|
| Vercel | adapter-vercel führt Actions als Functions aus. SvelteKit 3 streicht dort die Edge-Runtime, also nutze den Standard. Setze die Variablen unter Environment Variables. |
| Netlify | adapter-netlify folgt der Frameworks API von Netlify, und das Publish-Verzeichnis ist jetzt eine Option des Adapters. Setze die Variablen in den Umgebungseinstellungen der Site. |
| Cloudflare | adapter-cloudflare baut für Workers und braucht Wrangler 4.118 oder neuer als Peer-Dependency. Seine Doku empfiehlt $app/env für Variablen. Nutze für den Webhook eine eigene Domain; Ikiform lehnt workers.dev und pages.dev ab. |
Kostenlos auf localhost testen
Ein Workspace ohne Tarif nimmt 50 Testeinsendungen pro Monat von localhost an, und der weitergereichte Origin macht jede Anmeldung vom Dev-Server zu einer davon. Der Limiter zählt auch dich, also starte den Dev-Server neu, wenn du beim Testen ans Limit kommst. Ikiform stellt keine Webhooks an localhost zu; mach den Dev-Server über einen Tunnel mit HTTPS-URL erreichbar, um die Route zu testen.
Prüfen, ob es funktioniert
- Starte
vite dev, öffne eine beliebige Seite und abonniere über den Footer. Die Zeile erscheint im Footer, und die URL ändert sich nicht. - Deaktiviere JavaScript und abonniere erneut. Du landest auf
/newslettermit derselben Zeile. - Sende viermal innerhalb von zehn Minuten ab. Der vierte Versuch zeigt die Limit-Meldung, und der Netzwerk-Tab zeigt
429. - Öffne Submissions im Dashboard. Die neue Einsendung ist da, mit jedem Feld, das du gesendet hast.
- Wenn du einen Webhook hinzugefügt hast, öffne den Endpunkt unter Webhooks. Die Zustellung zeigt
succeededmit der Antwort deines Servers. - Öffne Kit. Der Abonnent ist als inactive da, und die Bestätigungs-E-Mail von Kit liegt in deinem Posteingang.
Stolperfallen
| Problem | Was zu tun ist |
|---|---|
Das Absenden des Footer-Formulars springt auch mit JavaScript zu /newsletter | Der use:enhance-Callback ruft update() ohne navigate: false auf. In SvelteKit 3 navigieren seitenübergreifende Actions standardmäßig. |
Der Build scheitert, weil /newsletter nicht vorgerendert werden kann | Ein Layout setzt prerender = true. Seiten mit Actions brauchen einen Server; setze prerender = false in der Server-Datei der Seite. |
| Sowohl der Footer als auch das Seitenformular zeigen das Ergebnis | Sie lesen dasselbe page.form. Vergleiche seine source mit der der Komponente, wie im Beispiel. |
402 plan_required bei lokalen Tests | Der Post hatte keinen localhost-Origin. Prüf die Origin-Zeile und öffne die Site unter localhost. |
| Die Einsendung ist im Dashboard, aber keine E-Mail und kein Webhook kam an | Sie wurde als Spam gespeichert. Spam löst nichts aus. Öffne sie unter dem Filter Spam, um die Gründe zu sehen, und markiere sie als kein Spam, um ihre E-Mail und Webhooks zu senden. |
Fragen
Dort kannst du keine definieren; Actions gehören in eine +page.server-Datei. Leg die Action auf eine Seite wie /newsletter und sende aus dem Layout mit action="/newsletter?/subscribe" daran. Nutze use:enhance mit update({ navigate: false }), um auf der aktuellen Seite zu bleiben.
Ja. Rendere die Artikel vor und überlass nur die Seite mit der Action und die Webhook-Route dem Server. Eine Site ganz ohne Server kann stattdessen direkt aus dem Browser an Ikiform senden.
Weil nur der Webhook weiß, wer echt ist. Die Form Action bekommt für einen Bot dieselbe Erfolgsantwort wie für einen Leser, während der Webhook nur für Anmeldungen feuert, die Ikiforms Spamprüfung bestehen, sodass Kit die Bots nie sieht.
Nein, es gibt keinen kostenlosen Tarif. Testen ist kostenlos: Du kannst 50 Testeinsendungen pro Monat von localhost senden, bevor du zahlst. Für den Livebetrieb startet Pro bei $6/Monat für 500 Einsendungen, oder du zahlst einmalig 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.