# Build a waitlist in SvelteKit

> A SvelteKit 3 waitlist: a hook keeps the first ?ref= in a cookie, a named form action forwards the signup, and returning visitors see they joined.

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

Read `?ref=` in `hooks.server.ts` on the visitor's first request and keep it in a cookie, so the signup is credited to the link that brought them even after they browse around. A named form action reads the cookie, forwards the signup to your form backend with a secret key, and sets a second cookie so the page greets a returning visitor instead of asking again.

The [main guide](https://ikiform.com/guides/waitlist.md#form) has a SvelteKit tab: a default action and `use:enhance`, with `ref` fixed to `homepage`. This page adds what a real launch needs. Visitors arrive on a blog post from a newsletter, read for a while and sign up two pages later. You want that signup credited to the newsletter, not to the page where they typed their email.

## What's different in SvelteKit

Two SvelteKit features fit this well, and one rule shapes the setup:

| Feature | What it does here |
| --- | --- |
| `handle` in `hooks.server.ts` | Runs before every server-rendered request, so it can see `?ref=` on whichever page the visitor lands |
| `event.cookies` | Stores the first source for 30 days. The action reads it back, with no hidden input to tamper with |
| Form actions with `use:enhance` | Post without JavaScript and enhance when it loads, with one piece of server code |
| The rule | A page with actions can't be prerendered, because a server must answer its `POST` |

A hidden input can't do this. It only knows the page the form is on, and a tag in the URL is gone after the first click. A cookie set on the server keeps it across every page, and the visitor never sees or edits it.

This is first-touch attribution: the first tagged link wins. A later visit from another link doesn't overwrite it. If you'd rather credit the last link, drop the `!event.cookies.get(...)` check.

Diagram: A SvelteKit visitor's first ?ref= follows them to the signup

1. A visitor lands on a blog post from a link with ?ref=news.
2. The handle hook sees the tag, finds no waitlist_ref cookie yet and sets one for 30 days.
3. They read pricing and come back to the home page. Neither URL has a tag; the cookie still does.
4. They sign up. The join action reads waitlist_ref and forwards the signup to Ikiform with ref set to news.
5. On success the action sets waitlist_joined, so the home page shows the thank-you line on their next visit instead of the form.

The source lives in an httpOnly cookie, not in the form, so it survives navigation and can't be edited in the page.

## Set up the project

Create the form and a secret key as in the [SvelteKit docs guide](https://ikiform.com/docs/guides/sveltekit.md). This page uses SvelteKit 3. The files:

`Project`:

```text
src/
  env.ts
  hooks.server.ts
  routes/
    +page.server.ts
    +page.svelte
    blog/[slug]/+page.svelte
vite.config.ts
.env
```

### Declare the secret

SvelteKit 3 deprecated the `$env/...` modules in favour of declared variables. Declare the key in `src/env.ts`; variables are private unless you mark them public, and `$app/env/private` can't be imported into browser code.

`src/env.ts`:

```ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

> 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 action sends `email` and `ref`. If the form has a field definition on its **Fields** tab, list both:

`Fields`:

```json
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}
```

## Build it

### Capture the source

`src/hooks.server.ts`:

```ts
import type { Handle } from "@sveltejs/kit/hooks";

const SOURCE = /^[a-z0-9_-]{1,40}$/;
const THIRTY_DAYS = 60 * 60 * 24 * 30;

export const handle: Handle = async ({ event, resolve }) => {
  const query = event.url.searchParams;
  const tag = (query.get("ref") ?? query.get("utm_source") ?? "").toLowerCase();

  if (SOURCE.test(tag) && !event.cookies.get("waitlist_ref")) {
    event.cookies.set("waitlist_ref", tag, { maxAge: THIRTY_DAYS, httpOnly: true, sameSite: "lax" });
  }
  return resolve(event);
};
```

- The tag is lowercased and must match the same pattern Ikiform's `max_length` allows, so a crafted link can't store junk.
- `httpOnly` keeps the cookie away from page scripts. `sameSite: "lax"` still sends it on the form's own `POST`.
- In SvelteKit 3 a cookie's `path` defaults to `/`, so it reaches every route without setting it.

### The named action

`src/routes/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions, PageServerLoad } from "./$types";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const YEAR = 60 * 60 * 24 * 365;

export const load: PageServerLoad = ({ cookies }) => ({
  joined: cookies.get("waitlist_joined") === "yes",
});

export const actions = {
  join: async ({ request, cookies, fetch, url }) => {
    const form = await request.formData();
    const email = String(form.get("email") ?? "").trim();
    if (!EMAIL.test(email)) return fail(400, { email, message: "Enter the email we should send your invite to." });

    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,
        ref: cookies.get("waitlist_ref") ?? "direct",
        _gotcha: String(form.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    }).catch(() => null);

    if (!response) return fail(504, { email, message: "We couldn't reach the list. Try again." });
    const result = await response.json().catch(() => null);
    if (!result?.ok) {
      return fail(response.status === 429 ? 429 : 400, {
        email,
        message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      });
    }

    cookies.set("waitlist_joined", "yes", { maxAge: YEAR, httpOnly: true, sameSite: "lax" });
    return { joined: true };
  },
} satisfies Actions;
```

- A named action, `join`, leaves room for others on this page later. SvelteKit doesn't allow a default action next to named ones.
- The browser's `Origin` header is passed on because Ikiform counts free `localhost` test submissions by it, and the action's own `fetch` sends none.
- `fail()` returns the typed email and a message. In SvelteKit 3, an enhanced submit uses the status you pass to `fail()`, so `page.status` reads `400` or `429`.
- `load` reads `waitlist_joined` so a returning visitor sees they're already in. That's a courtesy for one browser, not deduplication; Ikiform still stores a second signup from another device.

### The page

`src/routes/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { data, form }: PageProps = $props();
  let pending = $state(false);
</script>

<h1>Acme is almost here</h1>

{#if data.joined || form?.joined}
  <p role="status">You're on the list. We'll email you on launch day.</p>
{:else}
  <form
    method="POST"
    action="?/join"
    use:enhance={() => {
      pending = true;
      return async ({ update }) => {
        await update({ reset: false });
        pending = false;
      };
    }}
  >
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.email ?? ""} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if form?.message}
      <p role="alert">{form.message}</p>
    {/if}
    <button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
  </form>
{/if}
```

- `action="?/join"` names the action. Without JavaScript the browser posts there and SvelteKit renders the page with `form` set.
- `use:enhance` posts with `fetch` once the page is hydrated. `update({ reset: false })` keeps the email in the input after an error, so the visitor fixes a typo instead of retyping.
- `pending` disables the button until the action answers, which stops a double post on a slow phone.
- `data.joined` comes from `load` and `form.joined` from the action. Checking both covers a returning visitor and a fresh signup with one condition.

The honeypot reaches Ikiform untouched, so bots that fill it are stored as spam. Posts with the secret key skip Ikiform's human check, so turning **Spam check** on adds nothing here.

## Prerendering and deploy

The home page has an action, so it must render on the server. Blog posts and other content can be prerendered with `export const prerender = true`, but `handle` doesn't run when a visitor opens a prerendered file; it ran once at build time. A tagged link straight to a prerendered page sets no cookie. Point campaign links at a server-rendered page, such as the home page.

| Host | Adapter | Notes |
| --- | --- | --- |
| Vercel | `@sveltejs/adapter-vercel` | SvelteKit 3 dropped the `edge` runtime here, so the action runs on Node functions |
| Netlify | `@sveltejs/adapter-netlify` | Set the key under **Environment variables** |
| Cloudflare | `@sveltejs/adapter-cloudflare` | Needs `wrangler` 4.118 or later, the adapter's peer dependency. Set the key with `wrangler secret put IKIFORM_SECRET_KEY` |

## Check it works

1. Run `vite dev`, open `http://localhost:5173/?ref=test`, then remove the query and reload. The `waitlist_ref` cookie is still `test` in the browser's dev tools.
2. Sign up. The thank-you line replaces the form without a reload, and the signup in **Submissions** has `ref` set to `test`.
3. Reload the page. The thank-you line shows, not the form.
4. Clear cookies, turn JavaScript off and submit `a@b`. The page comes back with the error and the address still in the input.
5. Post with the honeypot filled in, `_gotcha=bot`. It still gets a success answer, but the submission is stored as spam and sends no email or webhook.
6. Posts from `vite dev` carry a `localhost` origin, so they count as test submissions. 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).

## Gotchas

| Problem | What to do |
| --- | --- |
| The build fails saying a page with actions can't be prerendered | A layout sets `prerender = true` for every route. Set `export const prerender = false` in the waitlist page's `+page.server.ts`. |
| `ref` is always `direct` | The tagged link pointed at a prerendered page, so `handle` never saw it, or an earlier visit already set the cookie. Check the cookie in dev tools. |
| `$env/static/private` warnings after upgrading | SvelteKit 3 deprecated the `$env` modules. Declare the key in `src/env.ts` and import it from `$app/env/private`. |
| `422` with `unknown_field` | The field definition doesn't list `ref`. Add it as in the definition above. |
| `402 plan_required` | The workspace has no plan and the post came from a live site. It is held, not lost. Pick a plan or buy a pack under **Settings › Plan and usage** and held submissions arrive. |

## Questions

### How do I track UTM sources in a SvelteKit form?

Read the query in the handle hook on the first request, store the tag in an httpOnly cookie, and read the cookie in the form action. The source survives navigation and never appears in the form.

### Can a SvelteKit page with a form action be prerendered?

No. A page with actions needs a server to answer its POST, so SvelteKit refuses to prerender it. Prerender the other pages and keep the form page server-rendered.

### Does the waitlist_joined cookie stop duplicate signups?

Only in that browser. Ikiform doesn't deduplicate by email, so remove repeats where the list lives or when you export it.

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

- [Waitlist for a new SaaS](https://ikiform.com/guides/waitlist.md): Build a pre-launch waitlist on your own landing page: the form, spam protection, duplicates, confirmation emails and a clean export on launch day.
- [Next.js waitlist](https://ikiform.com/guides/waitlist/nextjs.md): A Next.js App Router waitlist: a server action that forwards signups with a secret key, useActionState, an optimistic thank-you and a per-visitor limit.
- [Astro waitlist](https://ikiform.com/guides/waitlist/astro.md): An Astro waitlist on a static landing page: an Action forwards each signup, a script calls it, and one on-demand page catches posts without JavaScript.
- [Product Hunt launch list](https://ikiform.com/guides/waitlist/product-hunt.md): Collect a launch list weeks before Product Hunt, tag each signup by channel, and send the launch-day email in two waves timed to the Pacific Time reset.
- [Beta signup with screening](https://ikiform.com/guides/waitlist/beta-signup.md): A beta application form with screening questions the server enforces, and a script that scores applicants and picks each cohort from the list.
- [Mobile app waitlist](https://ikiform.com/guides/waitlist/mobile-app.md): A pre-launch waitlist for an iOS or Android app that asks which phone people use, sends iPhone testers to TestFlight and fills a Play closed test.
