Warteliste für ein neues SaaS

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.

Preet SutharGründer, Ikiform

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:

DetailAuswirkung auf die Warteliste
routeRules mit prerender: trueDie Landingpage wird einmal als HTML gebaut. Die Server-Route läuft weiterhin pro Request
readBody parst auch urlencoded BodysEin 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.

Eine Nitro-Route beantwortet $fetch mit JSON und einen einfachen Post mit einer Weiterleitungvorgerendertes/onMountedliest?ref=server/api/waitlist.post.tsSame-Site-Prüfung,ZodIkiformSecretKey,speichertsie{joined:true}$fetch,gleicheSeite303/thanksoder/?problem=emailvorgerenderteSeitezeigtdasErgebnisPOSTJSONContent-Type JSONurlencodedEine Nitro-Route beantwortet $fetch mit JSON und einen einfachen Post mit einer Weiterleitungvorgerendertes /onMounted liest ?ref=server/api/waitlist.post.tsSame-Site-Prüfung,ZodIkiformSecret Key, speichertsie{ joined: true }$fetch, gleiche Seite303/thanksoder/?problem=emailvorgerenderteSeitezeigt dasErgebnisPOSTJSONContent-Type JSONurlencoded
  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. Die Dateien auf dieser Seite, im app/-Layout von Nuxt 4:

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

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

HostEinrichtungDas Secret
VercelKeine Konfiguration. Vercel erkennt Nitro beim ImportNUXT_IKIFORM_SECRET_KEY unter Environment Variables
NetlifyKeine Konfiguration. Nuxt erkennt den Netlify-BuildNUXT_IKIFORM_SECRET_KEY unter Environment variables
Cloudflare PagesKeine Konfiguration mit Git-Integration. Für einen direkten Upload baue mit --preset=cloudflare_pagesNUXT_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.

Stolperfallen

ProblemWas zu tun ist
404 auf /api/waitlist in der ProduktionDie 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 refDie Query wird beim Rendern gelesen. Lies sie in onMounted, wie oben; das vorgerenderte HTML hat nie einen Query-String.
Jeder Post antwortet mit failedDer 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 refDie Felddefinition listet sie nicht auf. Füge beide hinzu, wie in der Definition oben.
402 plan_requiredDer 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

Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.