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

Published 2026-10-25 · https://ikiform.com/guides/lead-capture/sveltekit

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](https://ikiform.com/guides/lead-capture.md#form) 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.

Diagram: What SvelteKit does with each kind of action result

1. The handle hook sees each GET. With no lead_source cookie yet and a UTM tag or outside referrer, it sets one.
2. SvelteKit resolves the request and renders the page as usual.
3. On submit, use:enhance posts the form with fetch and sets the pending state.
4. The action validates with Valibot, reads the cookie and forwards the lead with the visitor's Origin.
5. A redirect to Cal.com can't be a client navigation, so SvelteKit loads it as a full page.
6. A fail result updates the form prop; update with reset false keeps the inputs as typed.

Without JavaScript the browser does the same two things itself: follows the 303 or renders the page with form.

## 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](https://ikiform.com/docs/guides/sveltekit.md) covers the key and the form.

`files`:

```text
src/
  env.ts
  hooks.server.ts
  routes/
    contact-sales/
      +page.server.ts
      +page.svelte
      thanks/+page.svelte
vite.config.ts
.env
```

`src/env.ts`:

```ts
import { 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 |

> 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 first touch in the handle hook

`src/hooks.server.ts`:

```ts
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 `path` to `/`. Setting it explicitly keeps the sample working on SvelteKit 2, where `path` is 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

`src/routes/contact-sales/+page.server.ts`:

```ts
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 `picklist` rejects an empty `select` with your message, and `flatten` gives 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 the `fail` status as the HTTP status.
- The browser's `Origin` header is forwarded, because a server-side fetch sends none and Ikiform detects free `localhost` tests from it.
- `redirect()` throws, so nothing after it runs. Since SvelteKit 2 you call it without `throw`, 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 = false` makes that explicit in case a parent layout turns prerendering on.

## The page

`src/routes/contact-sales/+page.svelte`:

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

## 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](https://ikiform.com/pricing).

1. Run `vite dev`, open `/?utm_source=test&utm_medium=email`, and check the `lead_source` cookie in dev tools.
2. On `/contact-sales`, pick a company size, leave **Budget** empty and submit. The budget error shows and your other answers stay.
3. 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.
4. Submit as **Later this year**. You land on `/contact-sales/thanks`.
5. Open **Submissions** in the dashboard and check `utm_medium`, `landing_page` and `fit`.
6. 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 `email` field.

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

### Can a SvelteKit form action redirect to an external URL?

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.

### How do I read the secret key in SvelteKit 3?

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.

### How do I keep form values after a failed SvelteKit action?

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.

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

- [Lead capture form](https://ikiform.com/guides/lead-capture.md): A lead capture form on your own site that emails you with reply-to, pings team chat and feeds your CRM, so you answer while the lead is still there.
- [Nuxt lead form](https://ikiform.com/guides/lead-capture/nuxt.md): A Nuxt lead form: server middleware records first-touch UTMs, one Zod-validated server route serves fetch and plain posts, and good leads book a call.
- [Astro lead form](https://ikiform.com/guides/lead-capture/astro.md): An Astro lead form built on Actions: Zod validation, first-touch UTMs on a static site, an on-demand contact page and a redirect to your scheduler.
- [Demo request form](https://ikiform.com/guides/lead-capture/demo-request.md): A book-a-demo form that stores the lead, then sends the visitor to Cal.com or Calendly with their name and email already filled in.
- [Quote request form](https://ikiform.com/guides/lead-capture/quote-request.md): A request-a-quote form that collects briefs as shared links instead of uploads, puts a due date on every request, and keeps link-heavy leads out of spam.
- [Consultation request form](https://ikiform.com/guides/lead-capture/consultation-request.md): A consultation request form for accountants, lawyers, coaches and clinics that asks how and when to call, and leaves sensitive details for the call.
