# 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.

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

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](https://ikiform.com/de/guides/lead-capture.md#form) 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.

Diagramm: Eine Nuxt-Server-Route beantwortet ein hydriertes Formular und einen einfachen Formular-Post

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](https://ikiform.com/de/docs/guides/nuxt.md) behandelt Key und Formular.

`Dateien`:

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

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

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

## Server-Middleware für den ersten Kontakt

`server/middleware/first-touch.ts`:

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

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

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

```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](https://ikiform.com/de/guides/spam-protection/cloudflare-turnstile.md).

## 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](https://ikiform.com/de/pricing).

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

| 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

### Wie leite ich in Nuxt auf eine externe URL weiter?

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.

### Kann eine Nuxt-Server-Route sowohl $fetch als auch einen einfachen Formular-Post verarbeiten?

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.

### Wo sollte UTM-Tracking in Nuxt leben?

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.

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

- [Formular zur Lead-Erfassung](https://ikiform.com/de/guides/lead-capture.md): Ein Lead-Formular auf deiner eigenen Site, das dich per E-Mail mit Reply-To benachrichtigt, den Team-Chat anpingt und dein CRM füttert, damit du antwortest, solange der Lead noch da ist.
- [Lead-Formular mit SvelteKit](https://ikiform.com/de/guides/lead-capture/sveltekit.md): Ein Lead-Formular mit SvelteKit: Der handle-Hook hält UTMs des ersten Kontakts fest, eine Form Action validiert mit Valibot, und redirect() schickt qualifizierte Leads in einen Kalender.
- [Lead-Formular mit Next.js](https://ikiform.com/de/guides/lead-capture/nextjs.md): Ein Lead-Formular für den Next.js App Router, das in einer Server Action mit Zod validiert, UTMs des ersten Kontakts in einem Cookie hält und qualifizierte Leads in deinen Kalender schickt.
- [Formular für Demo-Anfragen](https://ikiform.com/de/guides/lead-capture/demo-request.md): Ein Formular zum Demo-Buchen, das den Lead speichert und den Besucher dann mit vorausgefülltem Namen und E-Mail zu Cal.com oder Calendly schickt.
- [Formular für Angebotsanfragen](https://ikiform.com/de/guides/lead-capture/quote-request.md): Ein Formular für Angebotsanfragen, das Briefings als geteilte Links statt als Uploads sammelt, jeder Anfrage ein Fälligkeitsdatum gibt und linklastige Leads aus dem Spam hält.
- [Formular für Beratungsanfragen](https://ikiform.com/de/guides/lead-capture/consultation-request.md): Ein Formular für Beratungsanfragen für Steuerberater, Anwälte, Coaches und Praxen, das fragt, wie und wann angerufen werden soll, und heikle Details für das Gespräch lässt.
