Formular zur Lead-Erfassung7 Min. Lesezeit
Qualifizierendes Lead-Formular mit Nuxt bauen
Ein Lead-Formular mit Nuxt: Server-Middleware hält UTMs des ersten Kontakts fest, eine mit Zod validierte Server-Route bedient fetch und einfache Posts, und gute Leads buchen einen Termin.
Halte UTM-Tags und Referrer beim ersten Seiten-Request in Nitro-Server-Middleware in einem httpOnly-Cookie fest. Sende das Formular an eine Server-Route, die mit Zod validiert, den Lead mit der Attribution an dein Formular-Backend weiterleitet und $fetch mit JSON beantwortet, einen einfachen Formular-Post mit einem 303. Auf dem Client schickt navigateTo(url, { external: true }) qualifizierte Leads zu deinem Terminplaner.
Der Nuxt-Tab der Hauptanleitung sendet einen Lead mit $fetch über eine Server-Route und zeigt eine Dankeszeile. Diese Seite baut die Vertriebsversion: Unternehmensgröße, Budget und Zeitplan, vom Server geprüft, die Kampagne hinter jedem Lead, eine Übergabe an deinen Kalender und ein Weg, der auch funktioniert, wenn JavaScript nicht lädt.
Was Nuxt ändert
Nuxt hat keine Form Actions. Ein Formular sendet an eine Nitro-Server-Route wie an jeden anderen Endpunkt, und damit musst du zwei Transportwege bedienen: $fetch mit JSON von der hydrierten Seite und einen URL-kodierten Post vom nackten HTML-Formular. readBody von h3 parst beides, also kann eine Route beides bedienen, solange sie jeden auf seine eigene Weise beantwortet.
Diese Bequemlichkeit hat einen Preis: Eine Route, die URL-kodierte Bodys akzeptiert, akzeptiert auch einfache Cross-Site-Formular-Posts. Die Doku von h3 warnt davor, und anders als SvelteKit oder Next.js fügt Nuxt keine Origin-Prüfung für dich hinzu. Die Route prüft den Origin selbst.
- Mit JavaScript sendet die Seite das reaktive lead-Objekt per $fetch als JSON.
- Die Server-Route lehnt Posts von anderen Origins ab, validiert mit Zod und leitet den Lead mit seiner Attribution weiter.
- Sie antwortet mit JSON und der nächsten URL, und navigateTo wechselt zu Cal.com oder zur Dankeseite.
- Ein Validierungsfehler wird mit createError geworfen, und die Seite liest die Feldmeldungen aus den Daten des Fehlers.
- Ohne JavaScript sendet der Browser das Formular URL-kodiert an dieselbe Route.
- Die Route führt dieselben Prüfungen aus und antwortet mit einem 303: zum Terminplaner, zur Dankeseite oder zurück zum Formular mit den Namen der fehlerhaften Felder.
Projekt-Setup
Starte mit einer Nuxt-4-App und installiere zod. Deklariere den Key in runtimeConfig und setze ihn als NUXT_IKIFORM_SECRET_KEY; Nuxt ordnet die Variable zur Laufzeit zu, wenn die Route useRuntimeConfig(event) liest. Die Nuxt-Seite der Doku behandelt Key und Formular.
app/
pages/
contact-sales.vue
contact-sales/thanks.vue
server/
middleware/first-touch.ts
api/lead.post.ts
utils/lead.ts
nuxt.config.ts
.envexport default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
});Füge die Felder im Tab Fields des Formulars hinzu, damit Ikiform die Validierung wiederholt und unbekannte Keys ablehnt.
| Feld-ID | Typ in Ikiform | Gesetzt von |
|---|---|---|
name, email, company | Text, E-Mail, Text | Dem Besucher |
company_size | Auswahl: 1_10, 11_50, 51_200, 201_1000, 1000_plus | Dem Besucher |
budget | Auswahl: under_10k, 10k_50k, 50k_plus, unsure | Dem Besucher |
timeline | Auswahl: this_quarter, next_quarter, later, researching | Dem Besucher |
message | Textarea, optional | Dem Besucher |
utm_source, utm_medium, utm_campaign, referrer, landing_page | Text, optional | Deinem Server, aus dem ersten Besuch |
fit | Select: qualified, nurture | Deinem Server, nach der Validierung |
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Server-Middleware für den ersten Kontakt
const TAGS = ["utm_source", "utm_medium", "utm_campaign"];
export default defineEventHandler((event) => {
if (event.method !== "GET" || getCookie(event, "lead_source")) return;
if (!getRequestHeader(event, "accept")?.includes("text/html")) return;
const url = getRequestURL(event);
const referer = getRequestHeader(event, "referer") ?? "";
const from = URL.canParse(referer) ? new URL(referer) : null;
const external = from && from.host !== url.host ? from.href.slice(0, 300) : "";
const source = new URLSearchParams();
for (const tag of TAGS) source.set(tag, url.searchParams.get(tag)?.slice(0, 100) ?? "");
if (!external && !TAGS.some((tag) => source.get(tag))) return;
source.set("referrer", external);
source.set("landing_page", url.pathname.slice(0, 200));
setCookie(event, "lead_source", source.toString(), {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
});
});- Nitro führt Dateien in
server/middlewarevor jedem Request aus und erwartet, dass sie nichts zurückgeben. Ein Rückgabewert würde den Request beenden. - Die
accept-Prüfung beschränkt das Cookie auf Seitenaufrufe, sodass API-Aufrufe und Assets es überspringen. getCookie,setCookieund die anderen Helper von h3 werden inserver/automatisch importiert.- Mit
routeRulesodernuxt generatevorgerenderte Seiten werden als Dateien ausgeliefert, also sieht die Middleware diese Besuche nie. Lass Kampagnen-Landingpages serverseitig rendern.
Eine Route, zwei Antworten
import { z } from "zod";
export const LeadSchema = z.object({
name: z.string().trim().min(1, "Tell us who to reply to.").max(120),
email: z.email("Enter a work email we can reply to."),
company: z.string().trim().min(1, "Which company is this for?").max(200),
company_size: z.enum(["1_10", "11_50", "51_200", "201_1000", "1000_plus"], { error: "Pick a company size." }),
budget: z.enum(["under_10k", "10k_50k", "50k_plus", "unsure"], { error: "Pick a budget range." }),
timeline: z.enum(["this_quarter", "next_quarter", "later", "researching"], { error: "Pick a timeline." }),
message: z.string().trim().max(5000).optional(),
_gotcha: z.string().optional(),
});
export type Lead = z.infer<typeof LeadSchema>;
export const isQualified = (lead: Lead) =>
lead.company_size !== "1_10" &&
(lead.budget === "10k_50k" || lead.budget === "50k_plus") &&
(lead.timeline === "this_quarter" || lead.timeline === "next_quarter");import { z } from "zod";
const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];
export default defineEventHandler(async (event) => {
const origin = getRequestHeader(event, "origin");
if (origin && new URL(origin).host !== getRequestHost(event)) {
throw createError({ status: 403, message: "Posts from other sites aren't accepted." });
}
const wantsJson = getRequestHeader(event, "content-type")?.includes("application/json");
const parsed = LeadSchema.safeParse(await readBody(event));
if (!parsed.success) {
const fields = z.flattenError(parsed.error).fieldErrors;
if (!wantsJson) return sendRedirect(event, `/contact-sales?invalid=${Object.keys(fields).join(",")}`, 303);
throw createError({ status: 400, message: "Check the highlighted fields.", data: { fields } });
}
const lead = parsed.data;
const stored = new URLSearchParams(getCookie(event, "lead_source") ?? "");
const qualified = isQualified(lead);
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${useRuntimeConfig(event).ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
...lead,
...Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""])),
fit: qualified ? "qualified" : "nurture",
}),
});
const result = await response.json().catch(() => null);
if (!result?.ok) {
if (!wantsJson) return sendRedirect(event, "/contact-sales?failed=1", 303);
throw createError({
status: response.status,
message: result?.error?.message ?? "We couldn't send that. Try again.",
data: { fields: result?.error?.fields ?? {} },
});
}
const next =
qualified && !lead._gotcha
? `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}`
: "/contact-sales/thanks";
return wantsJson ? { next } : sendRedirect(event, next, 303);
});- Exporte aus
server/utilswerden in Server-Routen automatisch importiert, deshalb brauchenLeadSchemaundisQualifiedkeinen Import. - Derselbe
Origin-Header des Browsers erfüllt zwei Aufgaben: Er blockiert Cross-Site-Posts und wird an Ikiform weitergereicht, weil ein serverseitiges fetch keinen sendet und Ikiform daran kostenloselocalhost-Tests erkennt. createErrorlegt die Feldmeldungen indata, und die Fehlerantwort von Nuxt verschachtelt sie, also liest der Clienterror.data.data.fields.- Ein einfacher Post kann ohne Session keinen Zustand zurücktragen, also leitet ein Fehler mit den Namen der fehlerhaften Felder im Query-String weiter. Der Besucher tippt neu; es ist der Fallback-Weg.
Die Seite
<script setup lang="ts">
const route = useRoute();
const lead = reactive({ name: "", email: "", company: "", company_size: "", budget: "", timeline: "", message: "", _gotcha: "" });
const errors = ref<Record<string, string[] | undefined>>({});
const failure = ref(route.query.failed ? "We couldn't send that. Email sales@example.com instead." : "");
const sending = ref(false);
for (const key of String(route.query.invalid ?? "").split(",").filter(Boolean)) {
errors.value[key] = ["Check this field."];
}
const selects = {
company_size: ["Company size", { "1_10": "1 to 10", "11_50": "11 to 50", "51_200": "51 to 200", "201_1000": "201 to 1,000", "1000_plus": "More than 1,000" }],
budget: ["Budget", { under_10k: "Under $10k", "10k_50k": "$10k to $50k", "50k_plus": "More than $50k", unsure: "Not sure yet" }],
timeline: ["When do you want to start?", { this_quarter: "This quarter", next_quarter: "Next quarter", later: "Later this year", researching: "Researching for now" }],
} as const;
async function send() {
sending.value = true;
errors.value = {};
failure.value = "";
try {
const { next } = await $fetch<{ next: string }>("/api/lead", { method: "POST", body: lead });
await navigateTo(next, { external: next.startsWith("https://") });
} catch (error) {
const data = (error as { data?: { message?: string; data?: { fields?: Record<string, string[]> } } }).data;
errors.value = data?.data?.fields ?? {};
if (!Object.keys(errors.value).length) failure.value = data?.message ?? "We couldn't send that. Try again.";
sending.value = false;
}
}
</script>
<template>
<form action="/api/lead" method="post" @submit.prevent="send">
<label for="name">Name</label>
<input id="name" v-model="lead.name" name="name" autocomplete="name" required />
<p v-if="errors.name" role="alert">{{ errors.name[0] }}</p>
<label for="email">Work email</label>
<input id="email" v-model="lead.email" name="email" type="email" autocomplete="email" required />
<p v-if="errors.email" role="alert">{{ errors.email[0] }}</p>
<label for="company">Company</label>
<input id="company" v-model="lead.company" name="company" autocomplete="organization" required />
<p v-if="errors.company" role="alert">{{ errors.company[0] }}</p>
<template v-for="([label, options], id) in selects" :key="id">
<label :for="id">{{ label }}</label>
<select :id="id" v-model="lead[id]" :name="id" required>
<option value="" disabled>Choose one</option>
<option v-for="(text, value) in options" :key="value" :value="value">{{ text }}</option>
</select>
<p v-if="errors[id]" role="alert">{{ errors[id]?.[0] }}</p>
</template>
<label for="message">Anything we should know?</label>
<textarea id="message" v-model="lead.message" name="message" rows="4" />
<input v-model="lead._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="failure" role="alert">{{ failure }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Talk to sales" }}</button>
</form>
</template>@submit.prevent übernimmt, sobald Nuxt hydriert hat, und das reaktive lead-Objekt behält jeden Wert über einen Fehler hinweg. navigateTo lehnt eine absolute URL ab, außer external ist gesetzt, also folgt das Flag der URL: extern für Cal.com, eine Client-Navigation für die Dankeseite. Vor der Hydration sendet das action-Attribut an dieselbe Route, und die Seite liest ?invalid=, um Felder zu markieren.
Honeypot und Menschlichkeitsprüfung
Der Honeypot steht im Schema und wird weitergeleitet, sodass Ikiform einen ausgefüllten als Spam speichert, und die Route schickt ihn auf die Dankeseite. Der Secret Key überspringt die Menschlichkeitsprüfung von Ikiform; verifiziere bei Bedarf ein Turnstile-Token in der Route, wie in Turnstile in einem Formular.
Hinweise zum Deployment
| Host | Gut zu wissen |
|---|---|
| Vercel | Nitro erkennt Vercel und baut Functions. Setze NUXT_IKIFORM_SECRET_KEY in den Umgebungsvariablen. |
| Netlify | Auch Netlify erkennt Nitro. Setze die Variable in den Einstellungen der Site. |
| Cloudflare | Baue mit dem Preset cloudflare_module und speichere die Variable mit wrangler secret put. useRuntimeConfig(event) mit dem Event zu lesen, holt sie zur Laufzeit. |
Prüfen, ob es funktioniert
Posts aus nuxt dev tragen einen localhost-Origin. 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.
- Starte
nuxt dev, öffne/?utm_source=test&utm_campaign=launchund prüf das Cookielead_sourcein den Entwicklertools. - Lass auf
/contact-salesTimeline leer und sende ab. Der Fehler erscheint, und die anderen Antworten bleiben. - Schalte JavaScript aus und sende auf dieselbe Weise ab. Du kommst zurück auf
/contact-sales?invalid=timeline, mit markiertem Feld. - Sende als qualifizierter Lead: 11 bis 50 Personen, mehr als $50k, dieses Quartal. Du landest auf Cal.com mit ausgefülltem Namen und E-Mail.
- Öffne Submissions im Dashboard und prüf
utm_campaign,referrerundfit. - Prüfe das Postfach jedes Benachrichtigungsempfängers. Jeder bekommt eine E-Mail mit den Feldern, und Reply geht an den Absender, wenn das Formular ein
email-Feld hat.
Stolperfallen
| Problem | Was zu tun ist |
|---|---|
navigateTo wirft eine Exception, wenn ein Lead sich qualifiziert | external: true fehlt. navigateTo lehnt absolute URLs zu anderen Sites ohne dieses Flag ab. |
| Der Key ist in Produktion leer | Die Variable muss NUXT_IKIFORM_SECRET_KEY heißen, und die Route muss useRuntimeConfig(event) aufrufen, damit sie zur Laufzeit gelesen wird. |
403 Cross-site post hinter einem Proxy | Der Proxy ändert den Host, den die Route sieht. Reich den ursprünglichen Host weiter oder vergleiche den Origin stattdessen mit deiner öffentlichen Domain. |
| Das Cookie wird bei API-Aufrufen oder Bildern gesetzt | Die accept-Prüfung wurde entfernt. Beschränke die Middleware auf Seitenaufrufe. |
422 validation_failed | Der Post passt nicht zur Felddefinition des Formulars. error.fields nennt jedes Feld und den Grund. Gleiche den name des Eingabefelds an die Feld-ID an. |
Fragen
Ruf auf dem Client navigateTo(url, { external: true }) auf; ohne das Flag wirft es bei einer anderen Site eine Exception. In einer Server-Route beantwortet sendRedirect(event, url, 303) einen einfachen Formular-Post mit einer Weiterleitung.
Ja. readBody parst JSON und URL-kodierte Bodys, also prüfe den Content-Type und antworte $fetch mit JSON und einem nativen Post mit einer 303-Weiterleitung. Prüf auch den Origin-Header, denn die Route akzeptiert jetzt Cross-Site-Formular-Posts.
In einer Datei unter server/middleware. Sie läuft vor jedem Server-Request, kann Query-String und Referrer lesen und setzt ein httpOnly-Cookie, das die Lead-Route später liest. Für vorgerenderte Seiten, die als Dateien ausgeliefert werden, läuft sie nicht.
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.