Waitlist for a new SaaS

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.

Preet SutharFounder, Ikiform

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:

DetailEffect on the waitlist
routeRules with prerender: trueThe landing page is built once as HTML. The server route still runs per request
readBody parses urlencoded bodies tooA 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.

One Nitro route answers $fetch with JSON and a plain post with a redirectprerendered/onMountedreads?ref=server/api/waitlist.post.tssame-sitecheck,ZodIkiformsecretkey,storesit{joined:true}$fetch,samepage303/thanksor/?problem=emailprerenderedpageshowstheresultPOSTJSONcontent-type jsonurlencodedOne Nitro route answers $fetch with JSON and a plain post with a redirectprerendered /onMounted reads ?ref=server/api/waitlist.post.tssame-site check, ZodIkiformsecret key, stores it{ joined: true }$fetch, same page303/thanksor/?problem=emailprerenderedpageshows theresultPOSTJSONcontent-type jsonurlencoded
  1. The landing page is static HTML. After it mounts, the component copies ?ref= or ?utm_source= into its source value.
  2. The form posts to the server route: as JSON through $fetch once Vue has hydrated, or urlencoded as a plain post before that.
  3. The route refuses cross-site posts, validates the body with Zod and forwards the signup to Ikiform with the secret key.
  4. A JSON post gets { joined: true }, or an error whose message the component shows under the form.
  5. 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.
The content type of the post decides the answer. Both pages the browser lands on are prerendered.

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:

Project
app/
  pages/index.vue
  pages/thanks.vue
  components/WaitlistForm.vue
server/
  api/waitlist.post.ts
nuxt.config.ts
.env
nuxt.config.ts
export 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.

.env
NUXT_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.

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:

Fields
{
  "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

server/api/waitlist.post.ts
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.ts suffix limits the route to POST. Any other method gets 405.
  • Sec-Fetch-Site is same-origin for your own pages and cross-site for a form elsewhere. Browsers set it and page scripts can't change it.
  • Zod's .catch() turns a bad role or ref into a safe value instead of failing the signup. Only a bad email is an error worth showing.
  • The browser's Origin header is passed on because Ikiform counts free localhost test submissions by it, and the route's own fetch sends 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

app/components/WaitlistForm.vue
<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 method and action. Before hydration the browser posts it urlencoded; after, @submit.prevent hands it to $fetch, which sends JSON.
  • onMounted reads 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 $fetch leaves out of the JSON body.
  • The $fetch error carries the route's message in data.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.

HostSetupThe secret
VercelZero config. Vercel detects Nitro on importNUXT_IKIFORM_SECRET_KEY under Environment Variables
NetlifyZero config. Nuxt detects the Netlify buildNUXT_IKIFORM_SECRET_KEY under Environment variables
Cloudflare PagesZero config with Git integration. For a direct upload, build with --preset=cloudflare_pagesNUXT_IKIFORM_SECRET_KEY as an encrypted variable in the project settings

Check it works

  1. Run nuxt dev, open http://localhost:3000/?ref=test, pick a role and sign up. The thank-you line replaces the form.
  2. Open Submissions in the dashboard. The signup has ref set to test and the role you picked.
  3. Turn JavaScript off and sign up with no role. You land on /thanks, and the submission has no role.
  4. With JavaScript off, submit a@b. You land on /?problem=email, and the message shows once you turn JavaScript back on and reload.
  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 nuxt 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
404 on /api/waitlist in productionThe 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 inputThe query is read during render. Read it in onMounted, as above; the prerendered HTML never has a query string.
Every post answers failedThe key is empty. The variable must be named NUXT_IKIFORM_SECRET_KEY to fill runtimeConfig.ikiformSecretKey.
422 with unknown_field for role or refThe field definition doesn't list them. Add both, 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.