Waitlist for a new SaaS

Waitlist for a new SaaS7 min read

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.

Preet SutharFounder, Ikiform

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

FeatureWhat it does here
handle in hooks.server.tsRuns before every server-rendered request, so it can see ?ref= on whichever page the visitor lands
event.cookiesStores the first source for 30 days. The action reads it back, with no hidden input to tamper with
Form actions with use:enhancePost without JavaScript and enhance when it loads, with one piece of server code
The ruleA 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.

A SvelteKit visitor's first ?ref= follows them to the signupDay oneSignup/blog/launch?ref=newsfirst visithandle hooksetswaitlist_ref=news/pricing, then /no query string?/join actionreads waitlist_refIkiformref = newswaitlist_joinedthe next visit saysyou're inrequestbrowsesJSONcookieA SvelteKit visitor's first ?ref= follows them to the signupDay oneSignup/blog/launch?ref=newsfirst visithandle hooksets waitlist_ref=news/pricing, then /no query string?/join actionreads waitlist_refIkiformref = newswaitlist_joinedthe next visit says you're inrequestbrowsesJSONcookie
  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. This page uses SvelteKit 3. The files:

Project
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
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});
.env
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 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
{
  "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
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
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
<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.

HostAdapterNotes
Vercel@sveltejs/adapter-vercelSvelteKit 3 dropped the edge runtime here, so the action runs on Node functions
Netlify@sveltejs/adapter-netlifySet the key under Environment variables
Cloudflare@sveltejs/adapter-cloudflareNeeds 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.

Gotchas

ProblemWhat to do
The build fails saying a page with actions can't be prerenderedA layout sets prerender = true for every route. Set export const prerender = false in the waitlist page's +page.server.ts.
ref is always directThe 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 upgradingSvelteKit 3 deprecated the $env modules. Declare the key in src/env.ts and import it from $app/env/private.
422 with unknown_fieldThe field definition doesn't list ref. Add it as in the definition above.
402 plan_requiredThe 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

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.