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

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/newsletter-signup/sveltekit

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](https://ikiform.com/de/guides/newsletter-signup.md#form) 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](https://ikiform.com/de/docs/guides/sveltekit.md).

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

Diagramm: Wie eine Footer-Anmeldung in SvelteKit auf die Seite zurückkehrt, auf der der Leser ist

1. Der Leser sendet das Footer-Formular ab. use:enhance schickt es im Hintergrund an die subscribe-Action auf /newsletter.
2. Die Action prüft die Einwilligungs-Checkbox und zählt dann die letzten Versuche von der IP des Lesers.
3. Über dem Limit gibt sie fail(429) zurück, und nichts wird gesendet.
4. Sonst sendet sie die Anmeldung mit dem Secret Key und dem Origin des Browsers an Ikiform.
5. 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.

Nur das Formular, dessen source zum Ergebnis passt, reagiert, sodass sich Footer- und Seitenformular nicht beide ändern.

## Projekt-Setup

`Projektstruktur`:

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

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

Erstelle den Ikiform-Key unter **API keys** mit dem Scope `submissions:write` und füge die [Felddefinition der Hauptanleitung](https://ikiform.com/de/guides/newsletter-signup.md#fields) 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.

`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;
}
```

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

`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;
```

- Das Ergebnis ist in `newsletter` verpackt, sodass es nie mit dem Ergebnis eines anderen Formulars in `page.form` kollidiert.
- `fail()` behält die eingetippte E-Mail. SvelteKit 3 sendet bei enhanced Formularen außerdem den Status aus `fail()` mit, sodass der Netzwerk-Tab `422` oder `429` zeigt.
- Die `Origin`-Zeile reicht den Origin des Browsers weiter, denn Ikiform erkennt kostenlose `localhost`-Tests an diesem Header, und das `fetch` eines Servers sendet keinen.
- `prerender = false` ist wichtig, wenn dein Root-Layout alles vorrendert: Seiten mit Actions können nicht vorgerendert werden.

> Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform **Allowed domains** und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das [Rate-Limit](https://ikiform.com/de/docs/rate-limits.md) Relaxed oder Custom, und halte `IKIFORM_SECRET_KEY` in reinen Server-Umgebungsvariablen.

### Die Formular-Komponente

`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` hält den Leser auf dem Artikel. `refreshAll: false` überspringt nach einer erfolgreichen Anmeldung das erneute Ausführen jeder `load`-Funktion, denn die Anmeldung ändert nichts, was sie laden.
- `reset: false` behä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, und `page.form` trägt das Ergebnis dorthin.

### Die Seite /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" />
```

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](https://ikiform.com/de/guides/newsletter-signup/kit.md) erklärt jede Antwort von Kit und ergänzt 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 });
};
```

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](https://ikiform.com/de/guides/spam-protection/cloudflare-turnstile.md) 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

1. Starte `vite dev`, öffne eine beliebige Seite und abonniere über den Footer. Die Zeile erscheint im Footer, und die URL ändert sich nicht.
2. Deaktiviere JavaScript und abonniere erneut. Du landest auf `/newsletter` mit derselben Zeile.
3. Sende viermal innerhalb von zehn Minuten ab. Der vierte Versuch zeigt die Limit-Meldung, und der Netzwerk-Tab zeigt `429`.
4. Öffne **Submissions** im Dashboard. Die neue Einsendung ist da, mit jedem Feld, das du gesendet hast.
5. Wenn du einen Webhook hinzugefügt hast, öffne den Endpunkt unter **Webhooks**. Die Zustellung zeigt `succeeded` mit der Antwort deines Servers.
6. Ö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

### Wie setze ich eine SvelteKit Form Action ins Layout?

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.

### Kann eine vorgerenderte SvelteKit-Site ein Newsletter-Formular haben?

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.

### Warum erreicht der Abonnent Kit über einen Webhook?

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.

### Ist Ikiform kostenlos?

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.

## Weitere Anleitungen

- [Newsletter-Anmeldeformular](https://ikiform.com/de/guides/newsletter-signup.md): Baue ein Newsletter-Anmeldeformular mit eigenem Markup, speichere die Einwilligung, blocke Bot-Anmeldungen und gib jeden Abonnenten an Kit, MailerLite, beehiiv oder ein anderes Tool weiter.
- [Kit (ConvertKit)](https://ikiform.com/de/guides/newsletter-signup/kit.md): Verbinde dein eigenes Newsletter-Formular mit Kit, über ein Relay, das den Abonnenten anlegt, ihn für die Incentive-E-Mail einem Kit-Formular hinzufügt und die Platzierung taggt.
- [Double-Opt-in-Verfahren](https://ikiform.com/de/guides/newsletter-signup/double-opt-in.md): Ikiform sendet keine Bestätigungs-E-Mails, also läuft Double-Opt-in in deinem E-Mail-Tool: Ein Relay legt jede Anmeldung als unbestätigt an, und das Tool sendet den Link.
- [Newsletter-Formular mit Nuxt](https://ikiform.com/de/guides/newsletter-signup/nuxt.md): Ein Newsletter-Formular mit Nuxt, das mit oder ohne JavaScript funktioniert: Eine Server-Route mit Turnstile-Prüfung sendet an Ikiform, und eine Nitro-Route beliefert beehiiv.
- [Statische Site](https://ikiform.com/de/guides/newsletter-signup/static-site.md): Füge einer statischen Site mit Hugo, Jekyll, Eleventy oder Astro ein Newsletter-Formular hinzu, halte den API-Key des E-Mail-Tools aus der Seite heraus und bestätige im Tool.
- [MailerLite](https://ikiform.com/de/guides/newsletter-signup/mailerlite.md): Verbinde dein eigenes Anmeldeformular mit MailerLite: Ein Relay fügt neue Leser für das Double Opt-in unbestätigt hinzu und bittet einen Abonnenten nie zweimal um Bestätigung.
