# Eine Warteliste in Nuxt bauen

> Eine Nuxt-Warteliste auf einer vorgerenderten Landingpage: Eine Nitro-Route nimmt $fetch und einfache Formular-Posts an, prüft sie mit Zod und leitet ohne JavaScript weiter.

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/waitlist/nuxt

Rendere die Landingpage mit `routeRules` vor und sende das Formular an eine Nitro-Server-Route, die E-Mail und Rolle mit Zod validiert und sie mit einem Secret Key aus `runtimeConfig` an dein Formular-Backend weiterleitet. Die Route beantwortet `$fetch` mit JSON und einen einfachen Formular-Post mit einer `303`-Weiterleitung, die Anmeldung funktioniert also, bevor die Seite hydriert.

Die [Hauptanleitung](https://ikiform.com/de/guides/waitlist.md#form) hat einen Nuxt-Tab: eine Server-Route, `runtimeConfig` und eine Komponente, die mit `$fetch` sendet. Dieses Formular braucht JavaScript, sendet nur die E-Mail und liefert die Seite bei jedem Request aus. Diese Version rendert die Landingpage vor, stellt eine Sortierfrage und funktioniert weiter, wenn das Bundle nicht geladen ist.

## Was in Nuxt anders ist

Nuxt rendert standardmäßig auf dem Server, und Nitro liefert den Ordner `server/` neben deinen Seiten aus. Drei Details prägen eine Warteliste:

| Detail | Auswirkung auf die Warteliste |
| --- | --- |
| `routeRules` mit `prerender: true` | Die Landingpage wird einmal als HTML gebaut. Die Server-Route läuft weiterhin pro Request |
| `readBody` parst auch urlencoded Bodys | Ein einfaches `<form>` kann an dieselbe Route senden wie `$fetch`, und ein Formular auf der Site eines anderen ebenfalls |
| Eine vorgerenderte Seite hat beim Build keinen Query-String | `?ref=` und `?problem=` müssen nach dem Mounten der Seite gelesen werden, nicht beim Rendern |

Die zweite Zeile hat zwei Seiten. Sie macht den Weg ohne JavaScript möglich, und deshalb prüft die Route den Header `Sec-Fetch-Site`, bevor sie irgendetwas tut: Sonst könnte ein Formular auf einer anderen Site Anmeldungen über deinen Schlüssel senden.

Diagramm: Eine Nitro-Route beantwortet $fetch mit JSON und einen einfachen Post mit einer Weiterleitung

1. Die Landingpage ist statisches HTML. Nach dem Mounten kopiert die Komponente ?ref= oder ?utm_source= in ihren Quellwert.
2. Das Formular sendet an die Server-Route: als JSON über $fetch, sobald Vue hydriert ist, oder davor urlencoded als einfacher Post.
3. Die Route lehnt Cross-Site-Posts ab, validiert den Body mit Zod und leitet die Anmeldung mit dem Secret Key an Ikiform weiter.
4. Ein JSON-Post bekommt { joined: true } oder einen Fehler, dessen Meldung die Komponente unter dem Formular zeigt.
5. Ein einfacher Post bekommt einen 303 zu /thanks oder zurück zu / mit einem Problemcode, den die Seite nach dem Mounten in eine Meldung verwandelt.

Der Content-Type des Posts entscheidet über die Antwort. Beide Seiten, auf denen der Browser landet, sind vorgerendert.

## Das Projekt einrichten

Erstelle das Formular und den Secret Key wie in der [Nuxt-Anleitung in der Doku](https://ikiform.com/de/docs/guides/nuxt.md). Die Dateien auf dieser Seite, im `app/`-Layout von Nuxt 4:

`Projekt`:

```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 },
  },
});
```

Ein leerer `ikiformSecretKey` in `runtimeConfig` wird zur Laufzeit aus `NUXT_IKIFORM_SECRET_KEY` gefüllt. Schlüssel außerhalb von `public` existieren nur auf dem Server. Füge Zod mit `npm install zod` hinzu.

`.env`:

```bash
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

> Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform **Allowed domains** und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das [Rate-Limit](https://ikiform.com/de/docs/rate-limits.md) Relaxed oder Custom, und halte `IKIFORM_SECRET_KEY` in reinen Server-Umgebungsvariablen.

Dieses Formular sendet `email`, `role` und `ref`. Wenn du im Tab **Fields** des Formulars eine Felddefinition hinzufügst, liste alle drei auf, mit denselben Rollenwerten wie im Select:

`Felder`:

```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 } }
  ]
}
```

## Bauen

### Die 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");
});
```

- Das Suffix `.post.ts` beschränkt die Route auf `POST`. Jede andere Methode bekommt `405`.
- `Sec-Fetch-Site` ist `same-origin` für deine eigenen Seiten und `cross-site` für ein Formular anderswo. Browser setzen ihn, und Seitenskripte können ihn nicht ändern.
- Zods `.catch()` macht aus einer falschen `role` oder einem falschen `ref` einen sicheren Wert, statt die Anmeldung scheitern zu lassen. Nur eine falsche E-Mail ist ein Fehler, den es sich zu zeigen lohnt.
- Der `Origin`-Header des Browsers wird weitergereicht, weil Ikiform kostenlose `localhost`-Testeinsendungen daran zählt und das eigene `fetch` der Route keinen sendet.
- `useRuntimeConfig(event)` liest den Schlüssel pro Request, ein nach dem Build auf dem Host gesetzter Wert wird also trotzdem übernommen.
- Eine Weiterleitung trägt einen kurzen Problemcode, nie die Meldung, ein präparierter Link kann also keinen Text auf deine Seite schreiben.

### Die Formular-Komponente

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

- Das Formular behält `method` und `action`. Vor der Hydration sendet der Browser es urlencoded; danach übergibt `@submit.prevent` es an `$fetch`, das JSON sendet.
- `onMounted` liest die Query. Sie beim Rendern zu lesen gäbe dem HTML aus dem Build einen leeren Wert und dem Browser einen anderen, ein Hydration-Mismatch.
- Eine leere Rolle wird als `undefined` gesendet, was `$fetch` aus dem JSON-Body weglässt.
- Der `$fetch`-Fehler trägt die Meldung der Route in `data.message`, die Komponente zeigt also die Formulierung des Servers.

Nutze `<WaitlistForm />` in `app/pages/index.vue` und schreib `app/pages/thanks.vue` mit der Dankeszeile. Der Honeypot wird unverändert weitergeleitet, Bots, die ihn ausfüllen, werden also als Spam gespeichert. Posts mit dem Secret Key überspringen Ikiforms Menschlichkeitsprüfung, **Spam check** zu aktivieren bringt hier also nichts.

## Hinweise zum Deployment

Baue mit `nuxt build`. `nuxt generate` rendert jede Seite als statische Site ohne Server vor, `/api/waitlist` würde also nie laufen. Die `routeRules` oben geben dir statische Seiten und eine Live-Route aus einem Build.

| Host | Einrichtung | Das Secret |
| --- | --- | --- |
| Vercel | Keine Konfiguration. Vercel erkennt Nitro beim Import | `NUXT_IKIFORM_SECRET_KEY` unter **Environment Variables** |
| Netlify | Keine Konfiguration. Nuxt erkennt den Netlify-Build | `NUXT_IKIFORM_SECRET_KEY` unter **Environment variables** |
| Cloudflare Pages | Keine Konfiguration mit Git-Integration. Für einen direkten Upload baue mit `--preset=cloudflare_pages` | `NUXT_IKIFORM_SECRET_KEY` als verschlüsselte Variable in den Projekteinstellungen |

## Prüfen, ob es funktioniert

1. Führe `nuxt dev` aus, öffne `http://localhost:3000/?ref=test`, wähle eine Rolle und melde dich an. Die Dankeszeile ersetzt das Formular.
2. Öffne im Dashboard **Submissions**. Die Anmeldung hat `ref` auf `test` und die gewählte Rolle.
3. Schalte JavaScript aus und melde dich ohne Rolle an. Du landest auf `/thanks`, und die Einsendung hat keine `role`.
4. Sende mit ausgeschaltetem JavaScript `a@b` ab. Du landest auf `/?problem=email`, und die Meldung erscheint, sobald du JavaScript wieder einschaltest und neu lädst.
5. Sende einen Post mit ausgefülltem Honeypot, `_gotcha=bot`. Er bekommt trotzdem eine Erfolgsantwort, aber die Einsendung wird als Spam gespeichert und löst weder E-Mail noch Webhook aus.
6. Posts aus `nuxt dev` tragen einen `localhost`-Origin, zählen also als Testeinsendungen. Ikiform hat keinen kostenlosen Tarif. Testen ist kostenlos: Ein Workspace ohne Tarif nimmt 50 Testeinsendungen pro Monat von `localhost` an. Für den Livebetrieb wähle Pro ab $6/Monat für 500 Einsendungen oder zahle einmalig: 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt. Siehe [Preise](https://ikiform.com/de/pricing).

## Stolperfallen

| Problem | Was zu tun ist |
| --- | --- |
| `404` auf `/api/waitlist` in der Produktion | Die Site wurde mit `nuxt generate` gebaut, das keinen Server mitliefert. Baue mit `nuxt build` und behalte `prerender` in `routeRules`. |
| Eine Hydration-Mismatch-Warnung beim versteckten Eingabefeld `ref` | Die Query wird beim Rendern gelesen. Lies sie in `onMounted`, wie oben; das vorgerenderte HTML hat nie einen Query-String. |
| Jeder Post antwortet mit `failed` | Der Schlüssel ist leer. Die Variable muss `NUXT_IKIFORM_SECRET_KEY` heißen, um `runtimeConfig.ikiformSecretKey` zu füllen. |
| `422` mit `unknown_field` für `role` oder `ref` | Die Felddefinition listet sie nicht auf. Füge beide hinzu, wie in der Definition oben. |
| `402 plan_required` | Der Workspace hat keinen Tarif, und der Post kam von einer Live-Site. Er wird zurückgehalten, nicht verloren. Wähle einen Tarif oder kauf ein Paket unter **Settings › Plan and usage**, und zurückgehaltene Einsendungen kommen an. |

## Fragen

### Kann ein Nuxt-Formular ohne JavaScript funktionieren?

Ja. Gib dem Formular eine method und eine action, die auf eine Server-Route zeigen. Nitros readBody parst den urlencoded Post, und die Route kann ihn mit einer Weiterleitung beantworten, während $fetch-Posts von der hydrierten Seite JSON bekommen.

### Kann ich eine Nuxt-Seite mit Wartelisten-Formular vorrendern?

Ja. Rendere die Seite mit routeRules vor, halte die Server-Route des Formulars live und baue mit nuxt build. Lies Query-Werte wie ref nach dem Mounten der Seite.

### Wo bewahre ich den Secret Key in Nuxt auf?

In einem privaten runtimeConfig-Schlüssel, gefüllt aus einer NUXT_-Umgebungsvariable auf dem Host. Private Schlüssel sind nur auf dem Server lesbar, über useRuntimeConfig.

### Ist Ikiform kostenlos?

Nein, es gibt keinen kostenlosen Tarif. Testen ist kostenlos: Du kannst 50 Testeinsendungen pro Monat von localhost senden, bevor du zahlst. Für den Livebetrieb startet Pro bei $6/Monat für 500 Einsendungen, oder du zahlst einmalig 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt.

## Weitere Anleitungen

- [Warteliste für ein neues SaaS](https://ikiform.com/de/guides/waitlist.md): Baue eine Pre-Launch-Warteliste auf deiner eigenen Landingpage: das Formular, Spamschutz, Duplikate, Bestätigungs-E-Mails und ein sauberer Export am Launch-Tag.
- [Warteliste mit Next.js](https://ikiform.com/de/guides/waitlist/nextjs.md): Eine Warteliste mit dem Next.js App Router: eine Server Action, die Anmeldungen mit Secret Key weiterleitet, useActionState, ein optimistisches Danke und ein Limit pro Besucher.
- [Warteliste mit SvelteKit](https://ikiform.com/de/guides/waitlist/sveltekit.md): Eine SvelteKit-3-Warteliste: Ein Hook behält das erste ?ref= in einem Cookie, eine benannte Form-Action leitet die Anmeldung weiter, und wiederkehrende Besucher sehen, dass sie dabei sind.
- [Beta-Anmeldung mit Auswahl](https://ikiform.com/de/guides/waitlist/beta-signup.md): Ein Beta-Bewerbungsformular mit Auswahlfragen, die der Server durchsetzt, und ein Skript, das Bewerber bewertet und jede Kohorte aus der Liste auswählt.
- [Launch-Liste für Product Hunt](https://ikiform.com/de/guides/waitlist/product-hunt.md): Sammle Wochen vor Product Hunt eine Launch-Liste, markiere jede Anmeldung nach Kanal und sende die Launch-E-Mail in zwei Wellen, abgestimmt auf den Reset nach Pacific Time.
- [Warteliste für mobile Apps](https://ikiform.com/de/guides/waitlist/mobile-app.md): Eine Pre-Launch-Warteliste für eine iOS- oder Android-App, die fragt, welches Handy jemand nutzt, iPhone-Tester zu TestFlight schickt und einen Play-Closed-Test füllt.
