Waitlist for a new SaaS7 min read
Build a waitlist in Nuxt
A Nuxt waitlist on a prerendered landing page: one Nitro route takes $fetch and plain form posts, checks them with Zod and redirects without JavaScript.
Prerender the landing page with routeRules and post the form to a Nitro server route that validates the email and role with Zod and forwards them to your form backend with a secret key from runtimeConfig. The route answers JSON to $fetch and a 303 redirect to a plain form post, so the signup works before the page hydrates.
The main guide has a Nuxt tab: a server route, runtimeConfig and a component that posts with $fetch. That form needs JavaScript, sends only the email and serves the page on every request. This version prerenders the landing page, asks one sorting question, and keeps working when the bundle hasn't loaded.
What's different in Nuxt
Nuxt renders on the server by default, and Nitro serves the server/ folder next to your pages. Three details shape a waitlist:
| Detail | Effect on the waitlist |
|---|---|
routeRules with prerender: true | The landing page is built once as HTML. The server route still runs per request |
readBody parses urlencoded bodies too | A plain <form> can post to the same route as $fetch, and so can a form on someone else's site |
| A prerendered page has no query string at build time | ?ref= and ?problem= must be read after the page mounts, not during render |
The second row cuts both ways. It's what makes the no-JavaScript path work, and it's why the route checks the Sec-Fetch-Site header before doing anything: a form on another site could otherwise post signups through your key.
- The landing page is static HTML. After it mounts, the component copies ?ref= or ?utm_source= into its source value.
- The form posts to the server route: as JSON through $fetch once Vue has hydrated, or urlencoded as a plain post before that.
- The route refuses cross-site posts, validates the body with Zod and forwards the signup to Ikiform with the secret key.
- A JSON post gets { joined: true }, or an error whose message the component shows under the form.
- A plain post gets a 303 to /thanks, or back to / with a problem code that the page turns into a message after it mounts.
Set up the project
Create the form and the secret key as in the Nuxt docs guide. The files on this page, in Nuxt 4's app/ layout:
app/
pages/index.vue
pages/thanks.vue
components/WaitlistForm.vue
server/
api/waitlist.post.ts
nuxt.config.ts
.envexport default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
routeRules: {
"/": { prerender: true },
"/thanks": { prerender: true },
},
});An empty ikiformSecretKey in runtimeConfig is filled at runtime from NUXT_IKIFORM_SECRET_KEY. Keys outside public exist only on the server. Add Zod with npm install zod.
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY in server-only environment variables.This form sends email, role and ref. If you add a field definition on the form's Fields tab, list all three, with the same role values as the select:
{
"version": 1,
"title": "Waitlist",
"fields": [
{ "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
{ "id": "role", "type": "select", "label": "What do you do?", "options": [{ "value": "founder", "label": "Founder" }, { "value": "engineer", "label": "Engineer" }, { "value": "designer", "label": "Designer" }, { "value": "other", "label": "Something else" }] },
{ "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
]
}Build it
The server route
import { z } from "zod";
const Signup = z.object({
email: z.email(),
role: z.enum(["founder", "engineer", "designer", "other"]).optional().catch(undefined),
ref: z.string().regex(/^[a-z0-9_-]{1,40}$/).catch("direct"),
_gotcha: z.string().catch(""),
});
const MESSAGES = {
email: "Enter the email we should send your invite to.",
busy: "Lots of people are joining. Try again in a minute.",
failed: "Couldn't join the list. Try again.",
};
type Problem = keyof typeof MESSAGES;
export default defineEventHandler(async (event) => {
if (getRequestHeader(event, "sec-fetch-site") === "cross-site") {
throw createError({ status: 403, message: "Posts from other sites aren't accepted." });
}
const plain = !getRequestHeader(event, "content-type")?.includes("application/json");
const answer = (problem: Problem | null) => {
if (plain) return sendRedirect(event, problem ? `/?problem=${problem}` : "/thanks", 303);
if (problem) throw createError({ status: problem === "busy" ? 429 : 400, message: MESSAGES[problem] });
return { joined: true };
};
const parsed = Signup.safeParse(await readBody(event));
if (!parsed.success) return answer("email");
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(parsed.data),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
if (!response) return answer("failed");
if (response.status === 429) return answer("busy");
const result = await response.json().catch(() => null);
if (result?.ok) return answer(null);
return answer(result?.error?.fields?.email ? "email" : "failed");
});- The
.post.tssuffix limits the route toPOST. Any other method gets405. Sec-Fetch-Siteissame-originfor your own pages andcross-sitefor a form elsewhere. Browsers set it and page scripts can't change it.- Zod's
.catch()turns a badroleorrefinto a safe value instead of failing the signup. Only a bad email is an error worth showing. - The browser's
Originheader is passed on because Ikiform counts freelocalhosttest submissions by it, and the route's ownfetchsends none. useRuntimeConfig(event)reads the key per request, so a value set on the host after the build is still picked up.- A redirect carries a short problem code, never the message, so a crafted link can't write text onto your page.
The form component
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
email: "Enter the email we should send your invite to.",
busy: "Lots of people are joining. Try again in a minute.",
failed: "Couldn't join the list. Try again.",
};
const route = useRoute();
const email = ref("");
const role = ref("");
const source = ref("direct");
const trap = ref("");
const status = ref<"idle" | "sending" | "joined">("idle");
const error = ref("");
onMounted(() => {
const tag = String(route.query.ref ?? route.query.utm_source ?? "").toLowerCase().slice(0, 40);
if (tag) source.value = tag;
const problem = String(route.query.problem ?? "");
if (MESSAGES[problem]) error.value = MESSAGES[problem];
});
async function join() {
status.value = "sending";
error.value = "";
try {
await $fetch("/api/waitlist", {
method: "POST",
body: { email: email.value, role: role.value || undefined, ref: source.value, _gotcha: trap.value },
});
status.value = "joined";
} catch (failure) {
error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't join the list. Try again.";
status.value = "idle";
}
}
</script>
<template>
<p v-if="status === 'joined'" role="status">You're on the list. We'll email you on launch day.</p>
<form v-else method="post" action="/api/waitlist" @submit.prevent="join">
<label for="email">Email</label>
<input id="email" v-model="email" name="email" type="email" autocomplete="email" required />
<label for="role">What do you do?</label>
<select id="role" v-model="role" name="role">
<option value="">Choose one</option>
<option value="founder">Founder</option>
<option value="engineer">Engineer</option>
<option value="designer">Designer</option>
<option value="other">Something else</option>
</select>
<input type="hidden" name="ref" :value="source" />
<input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Joining" : "Join the waitlist" }}</button>
</form>
</template>- The form keeps
methodandaction. Before hydration the browser posts it urlencoded; after,@submit.preventhands it to$fetch, which sends JSON. onMountedreads the query. Reading it during render would give the build-time HTML an empty value and the browser a different one, a hydration mismatch.- An empty role is sent as
undefined, which$fetchleaves out of the JSON body. - The
$fetcherror carries the route's message indata.message, so the component shows the server's wording.
Use <WaitlistForm /> in app/pages/index.vue, and write app/pages/thanks.vue with the thank-you line. The honeypot is forwarded 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.
Deploy notes
Build with nuxt build. nuxt generate prerenders every page as a static site with no server, so /api/waitlist would never run. The routeRules above give you static pages and a live route from one build.
| Host | Setup | The secret |
|---|---|---|
| Vercel | Zero config. Vercel detects Nitro on import | NUXT_IKIFORM_SECRET_KEY under Environment Variables |
| Netlify | Zero config. Nuxt detects the Netlify build | NUXT_IKIFORM_SECRET_KEY under Environment variables |
| Cloudflare Pages | Zero config with Git integration. For a direct upload, build with --preset=cloudflare_pages | NUXT_IKIFORM_SECRET_KEY as an encrypted variable in the project settings |
Check it works
- Run
nuxt dev, openhttp://localhost:3000/?ref=test, pick a role and sign up. The thank-you line replaces the form. - Open Submissions in the dashboard. The signup has
refset totestand the role you picked. - Turn JavaScript off and sign up with no role. You land on
/thanks, and the submission has norole. - With JavaScript off, submit
a@b. You land on/?problem=email, and the message shows once you turn JavaScript back on and reload. - 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
nuxt 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 |
|---|---|
404 on /api/waitlist in production | The site was built with nuxt generate, which ships no server. Build with nuxt build and keep prerender in routeRules. |
A hydration mismatch warning on the hidden ref input | The query is read during render. Read it in onMounted, as above; the prerendered HTML never has a query string. |
Every post answers failed | The key is empty. The variable must be named NUXT_IKIFORM_SECRET_KEY to fill runtimeConfig.ikiformSecretKey. |
422 with unknown_field for role or ref | The field definition doesn't list them. Add both, 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
Yes. Give the form a method and an action that point at a server route. Nitro's readBody parses the urlencoded post, and the route can answer it with a redirect, while $fetch posts from the hydrated page get JSON.
Yes. Prerender the page with routeRules and keep the form's server route live, then build with nuxt build. Read query values such as ref after the page mounts.
In a private runtimeConfig key, filled from a NUXT_ environment variable on the host. Private keys are only readable on the server, through useRuntimeConfig.
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.