Lead capture form8 min read
Build a qualifying lead form in SvelteKit
A SvelteKit lead form: the handle hook records first-touch UTMs, a form action validates with Valibot, and redirect() sends qualified leads to a calendar.
Record UTM tags and the referrer in the handle hook on the first GET, in an httpOnly cookie. Validate the lead in a form action with Valibot, return fail() with the errors and values when it doesn't pass, and forward valid leads with the attribution to your form backend. Then call redirect(303, url, { external: true }) to send qualified leads to your scheduler; use:enhance follows it to the other site.
The SvelteKit tab on the main guide forwards a lead from a form action and shows a thank-you line. This page builds the version a sales team asks for: company size, budget and timeline validated on the server, the campaign behind each lead, and a hand-off to your calendar when the lead fits.
What SvelteKit changes
Form actions are SvelteKit's native way to handle a post, and they degrade cleanly: without JavaScript the browser posts and follows redirects itself, and with use:enhance the same action runs through fetch. A redirect result to another origin falls back to a full page load, so the Cal.com hand-off needs no extra client code.
SvelteKit also gives you one hook, handle, that wraps every server request. It's the right place to record the first touch. SvelteKit 3 changed two things this form touches: a redirect to another site needs { external: true }, and secrets are declared in src/env.ts and read from $app/env/private at runtime.
- The handle hook sees each GET. With no lead_source cookie yet and a UTM tag or outside referrer, it sets one.
- SvelteKit resolves the request and renders the page as usual.
- On submit, use:enhance posts the form with fetch and sets the pending state.
- The action validates with Valibot, reads the cookie and forwards the lead with the visitor's Origin.
- A redirect to Cal.com can't be a client navigation, so SvelteKit loads it as a full page.
- A fail result updates the form prop; update with reset false keeps the inputs as typed.
Project setup
Start from a SvelteKit 3 project, install valibot, and add your adapter. Put the secret key in .env as IKIFORM_SECRET_KEY and declare it in src/env.ts. It's private and read at runtime by default, and SvelteKit refuses to import $app/env/private into client code. The SvelteKit docs page covers the key and the form.
src/
env.ts
hooks.server.ts
routes/
contact-sales/
+page.server.ts
+page.svelte
thanks/+page.svelte
vite.config.ts
.envimport { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
IKIFORM_SECRET_KEY: {},
});Add the fields on the form's Fields tab, so Ikiform repeats the checks and refuses fields you didn't define.
| Field id | Type in Ikiform | Set by |
|---|---|---|
name, email, company | Text, email, text | The visitor |
company_size | Select: 1_10, 11_50, 51_200, 201_1000, 1000_plus | The visitor |
budget | Select: under_10k, 10k_50k, 50k_plus, unsure | The visitor |
timeline | Select: this_quarter, next_quarter, later, researching | The visitor |
message | Textarea, optional | The visitor |
utm_source, utm_medium, utm_campaign, referrer, landing_page | Text, optional | Your server, from the first visit |
fit | Select: qualified, nurture | Your server, after validation |
IKIFORM_SECRET_KEY in server-only environment variables.The first touch in the handle hook
import type { Handle } from "@sveltejs/kit/hooks";
const TAGS = ["utm_source", "utm_medium", "utm_campaign"];
export const handle: Handle = async ({ event, resolve }) => {
if (event.request.method === "GET" && !event.cookies.get("lead_source")) {
const referer = event.request.headers.get("referer") ?? "";
const from = URL.canParse(referer) ? new URL(referer) : null;
const external = from && from.host !== event.url.host ? from.href.slice(0, 300) : "";
const source = new URLSearchParams();
for (const tag of TAGS) source.set(tag, event.url.searchParams.get(tag)?.slice(0, 100) ?? "");
if (external || TAGS.some((tag) => source.get(tag))) {
source.set("referrer", external);
source.set("landing_page", event.url.pathname.slice(0, 200));
event.cookies.set("lead_source", source.toString(), {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30,
});
}
}
return resolve(event);
};- Only GET requests count, so the action's own POST never sets the cookie.
- SvelteKit 3 defaults a cookie's
pathto/. Setting it explicitly keeps the sample working on SvelteKit 2, wherepathis required. - A referrer from your own host is ignored, so internal clicks don't replace the real source.
- The hook doesn't run for prerendered pages served as files. Leave campaign landing pages rendered on demand, or the tags never reach it.
The form action
import { fail, redirect } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import * as v from "valibot";
import type { Actions } from "./$types";
export const prerender = false;
const Lead = v.object({
name: v.pipe(v.string(), v.trim(), v.nonEmpty("Tell us who to reply to."), v.maxLength(120)),
email: v.pipe(v.string(), v.trim(), v.email("Enter a work email we can reply to.")),
company: v.pipe(v.string(), v.trim(), v.nonEmpty("Which company is this for?"), v.maxLength(200)),
company_size: v.picklist(["1_10", "11_50", "51_200", "201_1000", "1000_plus"], "Pick a company size."),
budget: v.picklist(["under_10k", "10k_50k", "50k_plus", "unsure"], "Pick a budget range."),
timeline: v.picklist(["this_quarter", "next_quarter", "later", "researching"], "Pick a timeline."),
message: v.pipe(v.string(), v.trim(), v.maxLength(5000, "Keep it under 5,000 characters.")),
_gotcha: v.string(),
});
const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];
export const actions = {
default: async ({ request, cookies, fetch, url }) => {
const form = await request.formData();
const values = Object.fromEntries(Object.keys(Lead.entries).map((key) => [key, String(form.get(key) ?? "")]));
const parsed = v.safeParse(Lead, values);
if (!parsed.success) {
const nested = v.flatten<typeof Lead>(parsed.issues).nested ?? {};
const errors = Object.fromEntries(Object.entries(nested).map(([key, list]) => [key, list?.[0] ?? ""]));
return fail(400, { errors, values });
}
const lead = parsed.output;
const stored = new URLSearchParams(cookies.get("lead_source") ?? "");
const qualified =
lead.company_size !== "1_10" &&
(lead.budget === "10k_50k" || lead.budget === "50k_plus") &&
(lead.timeline === "this_quarter" || lead.timeline === "next_quarter");
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({
...lead,
...Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""])),
fit: qualified ? "qualified" : "nurture",
}),
});
const result = await response.json().catch(() => null);
if (!result?.ok) {
const fields: Record<string, string[]> = result?.error?.fields ?? {};
const errors = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""]));
if (!Object.keys(errors).length) errors.form = "We couldn't send that. Email sales@example.com instead.";
return fail(response.status >= 400 ? response.status : 502, { errors, values });
}
if (!qualified || lead._gotcha) redirect(303, "/contact-sales/thanks");
redirect(303, `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}`, { external: true });
},
} satisfies Actions;- Valibot's
picklistrejects an emptyselectwith your message, andflattengives one list of messages per field. fail()returns the values too, because the page has no other copy of them after a native post. SvelteKit 3 sends thefailstatus as the HTTP status.- The browser's
Originheader is forwarded, because a server-side fetch sends none and Ikiform detects freelocalhosttests from it. redirect()throws, so nothing after it runs. Since SvelteKit 2 you call it withoutthrow, and since SvelteKit 3 a target on another origin needs{ external: true }, or the action fails instead of redirecting.- Pages with actions can't be prerendered.
prerender = falsemakes that explicit in case a parent layout turns prerendering on.
The page
<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let pending = $state(false);
const errors = $derived<Record<string, string | undefined>>(form?.errors ?? {});
const values = $derived<Record<string, string | undefined>>(form?.values ?? {});
const selects = [
{ id: "company_size", label: "Company size", options: [["1_10", "1 to 10"], ["11_50", "11 to 50"], ["51_200", "51 to 200"], ["201_1000", "201 to 1,000"], ["1000_plus", "More than 1,000"]] },
{ id: "budget", label: "Budget", options: [["under_10k", "Under $10k"], ["10k_50k", "$10k to $50k"], ["50k_plus", "More than $50k"], ["unsure", "Not sure yet"]] },
{ id: "timeline", label: "When do you want to start?", options: [["this_quarter", "This quarter"], ["next_quarter", "Next quarter"], ["later", "Later this year"], ["researching", "Researching for now"]] },
];
</script>
<form
method="POST"
use:enhance={() => {
pending = true;
return async ({ update }) => {
await update({ reset: false });
pending = false;
};
}}
>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required value={values.name ?? ""} />
{#if errors.name}<p role="alert">{errors.name}</p>{/if}
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required value={values.email ?? ""} />
{#if errors.email}<p role="alert">{errors.email}</p>{/if}
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" required value={values.company ?? ""} />
{#if errors.company}<p role="alert">{errors.company}</p>{/if}
{#each selects as select (select.id)}
<label for={select.id}>{select.label}</label>
<select id={select.id} name={select.id} required value={values[select.id] ?? ""}>
<option value="" disabled>Choose one</option>
{#each select.options as [value, text] (value)}
<option {value}>{text}</option>
{/each}
</select>
{#if errors[select.id]}<p role="alert">{errors[select.id]}</p>{/if}
{/each}
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4">{values.message ?? ""}</textarea>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if errors.form}<p role="alert">{errors.form}</p>{/if}
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>use:enhance runs the default behaviour through update(), but reset: false keeps the inputs after a failure. Without JavaScript the action's fail data comes back in the form prop and the value attributes refill every field.
Honeypot and human check
The honeypot is in the schema, so it reaches Ikiform and a filled one is stored as spam; the action sends it to the thank-you page. SvelteKit's built-in CSRF check already rejects form posts from other origins. Ikiform's human check is skipped with the secret key, so verify a Turnstile token in the action if you need one, as in Turnstile on a form.
Deploy notes
| Host | What to know |
|---|---|
| Vercel | @sveltejs/adapter-vercel, or adapter-auto. Add IKIFORM_SECRET_KEY in the project's environment variables. |
| Netlify | @sveltejs/adapter-netlify. Set the key in site settings. |
| Cloudflare | @sveltejs/adapter-cloudflare. Store the key with wrangler secret put. It only exists at runtime, which $app/env/private handles, because declared variables are read when the app starts. |
Check it works
Posts from vite dev carry a localhost origin. Ikiform has no free plan. Testing is free: a workspace without a plan accepts 50 test submissions a month from localhost. To go live, pick Pro from $6/mo for 500 submissions, or pay once: 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire. See pricing.
- Run
vite dev, open/?utm_source=test&utm_medium=email, and check thelead_sourcecookie in dev tools. - On
/contact-sales, pick a company size, leave Budget empty and submit. The budget error shows and your other answers stay. - Submit as a qualified lead: 201 to 1,000 people, $10k to $50k, this quarter. The page loads Cal.com with name and email filled in.
- Submit as Later this year. You land on
/contact-sales/thanks. - Open Submissions in the dashboard and check
utm_medium,landing_pageandfit. - Check the inbox of each notification recipient. Each gets an email with the fields, and Reply goes to the submitter when the form has an
emailfield.
Gotchas
| Problem | What to do |
|---|---|
403 Cross-site POST form submissions are forbidden | SvelteKit's CSRF check saw an origin that doesn't match. Behind a proxy that doesn't pass the host, set paths.origin in the sveltekit() options in vite.config.ts; SvelteKit 3 removed adapter-node's ORIGIN variable. For another site of yours, add it to csrf.trustedOrigins. |
| The redirect to Cal.com fails with an error about an external URL | SvelteKit 3 refuses external redirects by default. Pass { external: true }, or an allowlist such as { external: ["https://cal.com"] }, as the third argument. |
| The page doesn't leave for Cal.com and the console shows an error | A custom use:enhance callback called goto() on the redirect. goto rejects external URLs; call update() or applyAction() instead. |
| Fields go blank after an error | update() was called without reset: false, or the inputs don't read form.values. |
422 validation_failed | The post doesn't match the form's field definition. error.fields names each field and the reason. Match the input name to the field id. |
Questions
Yes. Call redirect(303, 'https://...', { external: true }) in the action; SvelteKit 3 refuses an external target without that option. Without JavaScript the browser follows it, and with use:enhance SvelteKit falls back to a full page load because the URL isn't part of your app.
Declare it in src/env.ts with defineEnvVars and import it from $app/env/private. It's read at runtime unless you set static: true, so runtime-only secrets such as Cloudflare Workers secrets work. The $env modules still work but are deprecated.
Return them in fail(400, { errors, values }) and bind each input's value to form.values. With use:enhance, call update({ reset: false }) so the form isn't cleared.
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.