Newsletter signup form8 min read
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.
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 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.
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.
- The reader submits the footer form. use:enhance sends it to the subscribe action on /newsletter in the background.
- The action checks the consent box, then counts recent tries from the reader's IP.
- Over the limit, it returns fail(429) and nothing is sent.
- Otherwise it posts the signup to Ikiform with the secret key and the browser's Origin.
- 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.
Project 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 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.
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=1234567Create the Ikiform key under API keys with the submissions:write scope and paste the pillar's field definition 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.
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
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 inpage.form. fail()keeps the typed email. SvelteKit 3 also sends the status fromfail()over the wire for enhanced forms, so the network tab shows422or429.- The
Originline passes the browser's origin on, because Ikiform recognises freelocalhosttests by that header and a server'sfetchsends none. prerender = falsematters if your root layout prerenders everything: pages with actions can't be prerendered.
IKIFORM_SECRET_KEY in server-only environment variables.The form component
<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: falsekeeps the reader on the article.refreshAll: falseskips rerunning everyloadfunction after a successful signup, which changes nothing they load.reset: falsekeeps 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, andpage.formcarries the result there.
The /newsletter page
<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 explains each Kit answer and adds 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 });
};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 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
- Run
vite dev, open any page and subscribe from the footer. The line appears in the footer and the URL doesn't change. - Disable JavaScript and subscribe again. You land on
/newsletterwith the same line. - Submit four times within ten minutes. The fourth shows the limit message, and the network tab shows
429. - Open Submissions in the dashboard. The new submission is there with every field you sent.
- If you added a webhook, open the endpoint in Webhooks. The delivery shows
succeededwith your server's answer. - 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
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.
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.
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.
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.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.