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.
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:
| 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.
- A visitor lands on a blog post from a link with ?ref=news.
- The handle hook sees the tag, finds no waitlist_ref cookie yet and sets one for 30 days.
- They read pricing and come back to the home page. Neither URL has a tag; the cookie still does.
- They sign up. The join action reads waitlist_ref and forwards the signup to Ikiform with ref set to news.
- On success the action sets waitlist_joined, so the home page shows the thank-you line on their next visit instead of the form.
Set up the project
Create the form and a secret key as in the SvelteKit docs guide. This page uses SvelteKit 3. The files:
src/
env.ts
hooks.server.ts
routes/
+page.server.ts
+page.svelte
blog/[slug]/+page.svelte
vite.config.ts
.envDeclare 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.
import { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
IKIFORM_SECRET_KEY: {},
});IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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:
{
"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
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_lengthallows, so a crafted link can't store junk. httpOnlykeeps the cookie away from page scripts.sameSite: "lax"still sends it on the form's ownPOST.- In SvelteKit 3 a cookie's
pathdefaults to/, so it reaches every route without setting it.
The named action
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
Originheader is passed on because Ikiform counts freelocalhosttest submissions by it, and the action's ownfetchsends none. fail()returns the typed email and a message. In SvelteKit 3, an enhanced submit uses the status you pass tofail(), sopage.statusreads400or429.loadreadswaitlist_joinedso 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
<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 withformset.use:enhanceposts withfetchonce 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.pendingdisables the button until the action answers, which stops a double post on a slow phone.data.joinedcomes fromloadandform.joinedfrom 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
- Run
vite dev, openhttp://localhost:5173/?ref=test, then remove the query and reload. Thewaitlist_refcookie is stilltestin the browser's dev tools. - Sign up. The thank-you line replaces the form without a reload, and the signup in Submissions has
refset totest. - Reload the page. The thank-you line shows, not the form.
- Clear cookies, turn JavaScript off and submit
a@b. The page comes back with the error and the address still in the input. - 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. - Posts from
vite devcarry alocalhostorigin, 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 fromlocalhost. 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
| 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
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.
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.
Only in that browser. Ikiform doesn't deduplicate by email, so remove repeats where the list lives or when you export it.
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.