Formular zur Lead-Erfassung

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.

Preet SutharGründer, Ikiform

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.

Eine Nuxt-Server-Route beantwortet ein hydriertes Formular und einen einfachen Formular-Postmit JavaScriptohne JavaScript$fetchJSON-Bodylead.post.tsOrigin-Prüfung,Zod{ next }JSON-AntwortnavigateToexternal: trueerror.data.data.fieldsunter jedemFeldangezeigt<formaction>urlencodedlead.post.tsgleicheValidierungsendRedirect303Cal.com oder?invalid=POSTcreateError(400)POSTEine Nuxt-Server-Route beantwortet ein hydriertes Formular und einen einfachen Formular-Postmit JavaScriptohne JavaScript$fetchJSON-Bodylead.post.tsOrigin-Prüfung, Zod{ next }JSON-AntwortnavigateToexternal: trueerror.data.data.fieldsunter jedemFeldangezeigt<form action>urlencodedlead.post.tsgleiche ValidierungsendRedirect303Cal.com oder?invalid=POSTPOSTcreateError(400)
  1. Mit JavaScript sendet die Seite das reaktive lead-Objekt per $fetch als JSON.
  2. Die Server-Route lehnt Posts von anderen Origins ab, validiert mit Zod und leitet den Lead mit seiner Attribution weiter.
  3. Sie antwortet mit JSON und der nächsten URL, und navigateTo wechselt zu Cal.com oder zur Dankeseite.
  4. Ein Validierungsfehler wird mit createError geworfen, und die Seite liest die Feldmeldungen aus den Daten des Fehlers.
  5. Ohne JavaScript sendet der Browser das Formular URL-kodiert an dieselbe Route.
  6. 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.
Der Content-Type entscheidet die Antwort, nicht die Route. Validierung und der Post an Ikiform werden einmal geschrieben.

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.

Dateien
app/
  pages/
    contact-sales.vue
    contact-sales/thanks.vue
server/
  middleware/first-touch.ts
  api/lead.post.ts
  utils/lead.ts
nuxt.config.ts
.env
nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});

Füge die Felder im Tab Fields des Formulars hinzu, damit Ikiform die Validierung wiederholt und unbekannte Keys ablehnt.

Feld-IDTyp in IkiformGesetzt von
name, email, companyText, E-Mail, TextDem Besucher
company_sizeAuswahl: 1_10, 11_50, 51_200, 201_1000, 1000_plusDem Besucher
budgetAuswahl: under_10k, 10k_50k, 50k_plus, unsureDem Besucher
timelineAuswahl: this_quarter, next_quarter, later, researchingDem Besucher
messageTextarea, optionalDem Besucher
utm_source, utm_medium, utm_campaign, referrer, landing_pageText, optionalDeinem Server, aus dem ersten Besuch
fitSelect: qualified, nurtureDeinem Server, nach der Validierung
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.

Server-Middleware für den ersten Kontakt

server/middleware/first-touch.ts
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/middleware vor 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, setCookie und die anderen Helper von h3 werden in server/ automatisch importiert.
  • Mit routeRules oder nuxt generate vorgerenderte Seiten werden als Dateien ausgeliefert, also sieht die Middleware diese Besuche nie. Lass Kampagnen-Landingpages serverseitig rendern.

Eine Route, zwei Antworten

server/utils/lead.ts
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");
server/api/lead.post.ts
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/utils werden in Server-Routen automatisch importiert, deshalb brauchen LeadSchema und isQualified keinen 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 kostenlose localhost-Tests erkennt.
  • createError legt die Feldmeldungen in data, und die Fehlerantwort von Nuxt verschachtelt sie, also liest der Client error.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

app/pages/contact-sales.vue
<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

HostGut zu wissen
VercelNitro erkennt Vercel und baut Functions. Setze NUXT_IKIFORM_SECRET_KEY in den Umgebungsvariablen.
NetlifyAuch Netlify erkennt Nitro. Setze die Variable in den Einstellungen der Site.
CloudflareBaue 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.

  1. Starte nuxt dev, öffne /?utm_source=test&utm_campaign=launch und prüf das Cookie lead_source in den Entwicklertools.
  2. Lass auf /contact-sales Timeline leer und sende ab. Der Fehler erscheint, und die anderen Antworten bleiben.
  3. Schalte JavaScript aus und sende auf dieselbe Weise ab. Du kommst zurück auf /contact-sales?invalid=timeline, mit markiertem Feld.
  4. 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.
  5. Öffne Submissions im Dashboard und prüf utm_campaign, referrer und fit.
  6. 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

ProblemWas zu tun ist
navigateTo wirft eine Exception, wenn ein Lead sich qualifiziertexternal: true fehlt. navigateTo lehnt absolute URLs zu anderen Sites ohne dieses Flag ab.
Der Key ist in Produktion leerDie 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 ProxyDer 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 gesetztDie accept-Prüfung wurde entfernt. Beschränke die Middleware auf Seitenaufrufe.
422 validation_failedDer 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

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