# Build a newsletter signup form in SvelteKit

> A SvelteKit 3 newsletter form in the layout: a form action with consent and an IP limit, use:enhance that stays on the page, and a Kit webhook route.

Published 2026-10-25 · https://ikiform.com/guides/newsletter-signup/sveltekit

Put the form action on a `/newsletter` page and post to it from a form in the root layout. `use:enhance` with `update({ navigate: false })` applies the result to the page the reader is on, so the footer shows it without leaving the article, and without JavaScript the same post lands on `/newsletter`. The action checks consent, limits each IP with `getClientAddress()`, forwards the signup to your form backend, and a `+server.ts` route passes verified signups to Kit.

The [main guide's SvelteKit tab](https://ikiform.com/guides/newsletter-signup.md#form) posts from a footer to a `/newsletter` action and handles the result by hand. This page builds it the SvelteKit 3 way: explicit environment variables, `update({ navigate: false })` so the framework applies the result, a per-IP limit, and a webhook route for Kit. Product setup is in the [SvelteKit docs guide](https://ikiform.com/docs/guides/sveltekit.md).

## What a layout form changes in SvelteKit

Form actions live only in a `+page.server.ts`, never in a layout, so a footer form posts to another page's action. SvelteKit 3 made enhanced forms behave like native ones here: a form whose action is on another page navigates to that page. Newsletter widgets are the case the docs name for opting out, with `update({ navigate: false })`. The result is then applied to the current page as `page.form`, which the footer reads.

Diagram: How a SvelteKit footer signup returns to the page the reader is on

1. The reader submits the footer form. use:enhance sends it to the subscribe action on /newsletter in the background.
2. The action checks the consent box, then counts recent tries from the reader's IP.
3. Over the limit, it returns fail(429) and nothing is sent.
4. Otherwise it posts the signup to Ikiform with the secret key and the browser's Origin.
5. update with navigate false applies the result to the current page as page.form, and the footer shows it. Without JavaScript the browser goes to /newsletter, which renders the same component.

Only the form whose source matches the result reacts, so a footer and a page form don't both change.

## Project setup

`Project tree`:

```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 deprecates the `$env/*` modules in favour of explicit variables. Declare them in `src/env.ts`; they're private and read at runtime by default, and `$app/env/private` refuses to be imported into browser code.

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

Create the Ikiform key under **API keys** with the `submissions:write` scope and paste the [pillar's field definition](https://ikiform.com/guides/newsletter-signup.md#fields) on the form's **Fields** tab.

## Build it

### A per-IP limit

A person subscribes once. Three tries in ten minutes leaves room for a typo and stops a script. `getClientAddress()` returns the IP your adapter determined, so the limiter doesn't need to know which header your host uses.

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

The counter lives in one server's memory. On serverless hosts each instance counts on its own, so treat it as a speed bump and move it to a shared store when traffic grows. Files in a `server` directory can't be imported by browser code. SvelteKit 3 reaches `src/lib` through the `#lib` alias that a new project declares under `imports` in `package.json`; the old `$lib` alias is gone.

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

- The result is wrapped in `newsletter`, so it never collides with another form's result in `page.form`.
- `fail()` keeps the typed email. SvelteKit 3 also sends the status from `fail()` over the wire for enhanced forms, so the network tab shows `422` or `429`.
- The `Origin` line passes the browser's origin on, because Ikiform recognises free `localhost` tests by that header and a server's `fetch` sends none.
- `prerender = false` matters if your root layout prerenders everything: pages with actions can't be prerendered.

> The server-side versions post with a secret key, so Ikiform skips **Allowed domains** and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for a form you post through a server, and keep `IKIFORM_SECRET_KEY` in server-only environment variables.

### The form component

`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` keeps the reader on the article. `refreshAll: false` skips rerunning every `load` function after a successful signup, which changes nothing they load.
- `reset: false` keeps the typed email when there's an error. On success the form is replaced anyway.
- With JavaScript off, the post goes to `/newsletter`, which renders this same component, and `page.form` carries the result there.

### The /newsletter page

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

Render `<NewsletterForm />` in `+layout.svelte` for the footer, and pass `source="post"` at the end of articles.

## Forward subscribers to Kit

This `+server.ts` route receives Ikiform's signed webhook, creates the subscriber in Kit as inactive and adds them to a Kit form, whose incentive email is Kit's confirmation; test it once with an address you own. It reads the body as text first because the signature covers the exact bytes, and returns plain `Response` objects, since SvelteKit 3 deprecates the `json()` and `text()` helpers. The [Kit guide](https://ikiform.com/guides/newsletter-signup/kit.md) explains each Kit answer and adds 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 });
};
```

SvelteKit's CSRF protection is always on in version 3, but it only rejects cross-origin form submissions and cross-origin posts with no `Content-Type`. Ikiform sends `application/json`, so the webhook passes. Add the URL under **Webhooks** and copy the `whsec_` secret.

## Honeypot and the human check

The `_gotcha` input is forwarded and Ikiform stores a filled one as spam, with a success answer and no webhook, so Kit never sees it. The secret key skips Ikiform's Turnstile check; the IP limit above covers the loop a script would run. For bots that use many IPs, verify a Turnstile token in the action, as the [Turnstile guide](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md) shows.

## Deploy

| Host | What to do |
| --- | --- |
| Vercel | `adapter-vercel` runs actions as functions. SvelteKit 3 drops the edge runtime there, so use the default. Set the variables under **Environment Variables**. |
| Netlify | `adapter-netlify` follows Netlify's Frameworks API, and the publish directory is now an adapter option. Set the variables in the site's environment settings. |
| Cloudflare | `adapter-cloudflare` builds for Workers and needs Wrangler 4.118 or later, its peer dependency. Its docs recommend `$app/env` for variables. Use a custom domain for the webhook; Ikiform refuses `workers.dev` and `pages.dev`. |

## Test it on localhost for free

A workspace without a plan accepts 50 test submissions a month from `localhost`, and the forwarded `Origin` makes each dev-server signup one of them. The limiter counts you too, so restart the dev server if you hit it while testing. Ikiform won't deliver webhooks to `localhost`; expose the dev server through a tunnel with an HTTPS URL to test the route.

## Check it works

1. Run `vite dev`, open any page and subscribe from the footer. The line appears in the footer and the URL doesn't change.
2. Disable JavaScript and subscribe again. You land on `/newsletter` with the same line.
3. Submit four times within ten minutes. The fourth shows the limit message, and the network tab shows `429`.
4. Open **Submissions** in the dashboard. The new submission is there with every field you sent.
5. If you added a webhook, open the endpoint in **Webhooks**. The delivery shows `succeeded` with your server's answer.
6. Open Kit. The subscriber is there as inactive, and Kit's confirmation email is in your inbox.

## Gotchas

| Problem | What to do |
| --- | --- |
| Submitting the footer form jumps to `/newsletter` even with JavaScript | The `use:enhance` callback calls `update()` without `navigate: false`. In SvelteKit 3, cross-page actions navigate by default. |
| The build fails because `/newsletter` can't be prerendered | A layout sets `prerender = true`. Pages with actions need a server; set `prerender = false` in the page's server file. |
| Both the footer and the page form show the result | They read the same `page.form`. Compare its `source` with the component's, as the sample does. |
| `402 plan_required` on local tests | The post had no `localhost` origin. Check the `Origin` line, and open the site at `localhost`. |
| The submission is in the dashboard but no email or webhook arrived | It was stored as spam. Spam sends nothing. Open it under the **Spam** filter to see the reasons, and mark it as not spam to send its email and webhooks. |

## Questions

### How do I put a SvelteKit form action in the layout?

You can't define one there; actions belong to a +page.server file. Put the action on a page such as /newsletter and post to it from the layout with action="/newsletter?/subscribe". Use use:enhance with update({ navigate: false }) to stay on the current page.

### Can a prerendered SvelteKit site have a newsletter form?

Yes. Prerender the articles and leave only the page with the action, and the webhook route, to the server. A site with no server at all can post from the browser straight to Ikiform instead.

### Why does the subscriber reach Kit through a webhook?

Because only the webhook knows who is real. The form action gets the same success answer for a bot as for a reader, while the webhook fires only for signups that pass Ikiform's spam check, so Kit never sees the bots.

### Is Ikiform free?

No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.

## More guides

- [Newsletter signup form](https://ikiform.com/guides/newsletter-signup.md): Build a newsletter signup form in your own markup, record consent, block bot signups and pass each subscriber to Kit, MailerLite, beehiiv or any tool.
- [Kit (ConvertKit)](https://ikiform.com/guides/newsletter-signup/kit.md): Connect your own newsletter form to Kit with a relay that creates the subscriber, adds them to a Kit form for the incentive email, and tags the placement.
- [Double opt-in](https://ikiform.com/guides/newsletter-signup/double-opt-in.md): Ikiform doesn't send confirmation emails, so double opt-in runs in your email tool: a relay adds each signup as unconfirmed and the tool sends the link.
- [Nuxt newsletter form](https://ikiform.com/guides/newsletter-signup/nuxt.md): A Nuxt newsletter form that works with or without JavaScript: a server route with a Turnstile check posts to Ikiform, and a Nitro route feeds beehiiv.
- [Static site](https://ikiform.com/guides/newsletter-signup/static-site.md): Add a newsletter signup form to a Hugo, Jekyll, Eleventy or Astro static site, keep the email tool's API key out of the page and confirm in the tool.
- [MailerLite](https://ikiform.com/guides/newsletter-signup/mailerlite.md): Connect your own signup form to MailerLite: a relay adds new readers as unconfirmed for double opt-in and never asks a subscriber to confirm twice.
