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

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

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](https://ikiform.com/guides/waitlist.md#form) 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.

Diagram: One Nitro route answers $fetch with JSON and a plain post with a redirect

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](https://ikiform.com/docs/guides/nuxt.md). The files on this page, in Nuxt 4's `app/` layout:

`Project`:

```text
app/
  pages/index.vue
  pages/thanks.vue
  components/WaitlistForm.vue
server/
  api/waitlist.post.ts
nuxt.config.ts
.env
```

`nuxt.config.ts`:

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

```bash
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](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.

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

```json
{
  "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`:

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

```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.

| 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

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](https://ikiform.com/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

### Can a Nuxt form work without JavaScript?

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.

### Can I prerender a Nuxt page that has a waitlist form?

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.

### Where do I keep the secret key in Nuxt?

In a private runtimeConfig key, filled from a NUXT_ environment variable on the host. Private keys are only readable on the server, through useRuntimeConfig.

### 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.
- [SvelteKit waitlist](https://ikiform.com/guides/waitlist/sveltekit.md): 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.
- [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.
- [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.
- [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.
