Warteliste für ein neues SaaS7 Min. Lesezeit
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.
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 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.
- Die Landingpage ist statisches HTML. Nach dem Mounten kopiert die Komponente ?ref= oder ?utm_source= in ihren Quellwert.
- Das Formular sendet an die Server-Route: als JSON über $fetch, sobald Vue hydriert ist, oder davor urlencoded als einfacher Post.
- Die Route lehnt Cross-Site-Posts ab, validiert den Body mit Zod und leitet die Anmeldung mit dem Secret Key an Ikiform weiter.
- Ein JSON-Post bekommt { joined: true } oder einen Fehler, dessen Meldung die Komponente unter dem Formular zeigt.
- 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.
Das Projekt einrichten
Erstelle das Formular und den Secret Key wie in der Nuxt-Anleitung in der Doku. Die Dateien auf dieser Seite, im app/-Layout von Nuxt 4:
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 },
},
});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.
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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:
{
"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
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.tsbeschränkt die Route aufPOST. Jede andere Methode bekommt405. Sec-Fetch-Siteistsame-originfür deine eigenen Seiten undcross-sitefür ein Formular anderswo. Browser setzen ihn, und Seitenskripte können ihn nicht ändern.- Zods
.catch()macht aus einer falschenroleoder einem falschenrefeinen 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 kostenloselocalhost-Testeinsendungen daran zählt und das eigenefetchder 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
<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
methodundaction. Vor der Hydration sendet der Browser es urlencoded; danach übergibt@submit.preventes an$fetch, das JSON sendet. onMountedliest 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
undefinedgesendet, was$fetchaus dem JSON-Body weglässt. - Der
$fetch-Fehler trägt die Meldung der Route indata.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
- Führe
nuxt devaus, öffnehttp://localhost:3000/?ref=test, wähle eine Rolle und melde dich an. Die Dankeszeile ersetzt das Formular. - Öffne im Dashboard Submissions. Die Anmeldung hat
refauftestund die gewählte Rolle. - Schalte JavaScript aus und melde dich ohne Rolle an. Du landest auf
/thanks, und die Einsendung hat keinerole. - Sende mit ausgeschaltetem JavaScript
a@bab. Du landest auf/?problem=email, und die Meldung erscheint, sobald du JavaScript wieder einschaltest und neu lädst. - 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. - Posts aus
nuxt devtragen einenlocalhost-Origin, zählen also als Testeinsendungen. Ikiform hat keinen kostenlosen Tarif. Testen ist kostenlos: Ein Workspace ohne Tarif nimmt 50 Testeinsendungen pro Monat vonlocalhostan. 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.
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
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.
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.
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.
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.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.