# Qualifizierendes Lead-Formular mit Next.js bauen

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

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

Erfasse beim ersten Besuch Kampagnen-Tags und Referrer in `proxy.ts` und halte sie in einem Cookie. Validiere den Lead mit Zod in einer Server Action, bewerte ihn nach Unternehmensgröße, Budget und Zeitplan und leite ihn mit der Attribution an dein Formular-Backend weiter. Ruf dann `redirect()` auf, um qualifizierte Leads zu deinem Terminplaner und alle anderen auf eine Dankeseite zu schicken.

Der [Next.js-Tab der Hauptanleitung](https://ikiform.com/de/guides/lead-capture.md#form) leitet einen Lead weiter und zeigt eine Dankeszeile. Diese Seite baut die Vertriebsversion: drei qualifizierende Fragen, auf deinem Server geprüft, Kampagnendaten, die überleben, wenn der Besucher vor der Konversion herumklickt, und eine direkte Übergabe an deinen Kalender für Leads, die ein Gespräch wert sind.

## Was Next.js ändert

Zwei Funktionen von Next.js erledigen den Großteil. `proxy.ts`, die Datei, die vor Next.js 16 `middleware.ts` hieß, läuft vor jedem Seiten-Request, auch bei statischen Seiten. Damit ist sie der richtige Ort, um beim ersten Besuch `?utm_source=` und den `Referer`-Header zu lesen, denn bis der Besucher bei **Talk to sales** ankommt, sind beide aus der URL verschwunden. Die Server Action liest dann das Cookie, sodass die Kontaktseite selbst statisch bleiben kann.

Die zweite ist `redirect()`. In einer Server Action akzeptiert es eine absolute URL, sodass ein qualifizierter Lead ohne Client-Code auf Cal.com landet. Mit geladenem JavaScript navigiert Next.js auf dem Client; vor der Hydration beantwortet es den Formular-Post mit einem `303`.

Diagramm: Wie ein Lead in Next.js vom ersten Anzeigenklick bis zum gebuchten Termin gelangt

1. Der Besucher kommt über einen Kampagnenlink mit UTM-Tags oder über den Link einer anderen Site.
2. proxy.ts speichert die Tags, den Referrer und den Einstiegspfad einmalig in einem httpOnly-Cookie.
3. Preis- und Kontaktseite bleiben statisch und kommen aus dem Cache.
4. Beim Absenden validiert die Server Action die Felder mit Zod und liest das Cookie.
5. Sie sendet den Lead, die Attribution und eine berechnete Eignung mit dem Secret Key an Ikiform.
6. Ein qualifizierter Lead wird mit ausgefülltem Namen und E-Mail zum Terminplaner weitergeleitet; andere gehen auf die Dankeseite.
7. Ein Validierungsfehler kommt als Action-State zurück, mit jedem Wert, den der Besucher eingetippt hat.

Das Cookie trägt den ersten Kontakt über die Seiten hinweg, sodass die Kontaktseite die URL nie lesen muss.

## Projekt-Setup

Starte mit einem beliebigen App-Router-Projekt auf Next.js 16. Installiere `zod`, lege im Dashboard ein Formular an und unter **API keys** einen Secret Key mit dem Scope `submissions:write`. Die [Next.js-Seite der Doku](https://ikiform.com/de/docs/guides/nextjs.md) behandelt Key und Formular-Setup im Detail.

`Dateien`:

```text
app/
  contact-sales/
    page.tsx
    actions.ts
    lead-form.tsx
    thanks/page.tsx
lib/
  lead.ts
proxy.ts
.env.local
```

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

Füge dann die Felder im Tab **Fields** des Formulars hinzu, damit Ikiform sie ebenfalls durchsetzt. Ohne Definition speichert Ikiform, was du sendest; mit einer ist ein unbekanntes Feld ein `422`, und genau das willst du, sobald Attribution im Payload steckt.

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

## Attribution des ersten Kontakts in proxy.ts

`proxy.ts`:

```ts
import { type NextRequest, NextResponse } from "next/server";

const TAGS = ["utm_source", "utm_medium", "utm_campaign"];

export function proxy(request: NextRequest) {
  const response = NextResponse.next();
  if (request.cookies.has("lead_source")) return response;

  const url = request.nextUrl;
  const referer = request.headers.get("referer") ?? "";
  const from = URL.canParse(referer) ? new URL(referer) : null;
  const external = from && from.host !== url.host ? from.href : "";
  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 response;

  source.set("referrer", external.slice(0, 300));
  source.set("landing_page", url.pathname.slice(0, 200));
  response.cookies.set("lead_source", source.toString(), {
    httpOnly: true,
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 24 * 30,
  });
  return response;
}

export const config = {
  matcher: [
    {
      source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};
```

- Das Cookie wird einmal gesetzt und nie überschrieben, es hält also die erste Kampagne fest, nicht den letzten internen Klick. Lösche die `has`-Prüfung, wenn du den letzten Kontakt bevorzugst.
- Ein Referrer von deinem eigenen Host wird ignoriert. Sonst würde jeder Seitenaufruf nach dem ersten als Verweis von dir selbst zählen.
- Der Matcher überspringt statische Dateien und Router-Prefetches. Prefetches tragen deine eigene Seite als Referrer und würden sonst den Proxy für jeden Link auf dem Bildschirm ausführen.
- `URLSearchParams` liefert einen kompakten Cookie-Wert und einen sicheren Parser. Die Action liest nur die fünf Keys, die sie kennt, also kann ein Besucher, der das Cookie bearbeitet, keine Felder hinzufügen.

## Validierung, Bewertung und Übergabe

Das Schema lebt in einem eigenen Modul, damit die Action und jede künftige API-Route es teilen. Die Eignungsregel ist bewusst schlicht: mindestens elf Personen, ein Budget ab $10k und ein Start innerhalb von zwei Quartalen.

`lib/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(),
});

export type Lead = z.infer<typeof LeadSchema>;

export function isQualified(lead: Lead) {
  const bigEnough = lead.company_size !== "1_10";
  const funded = lead.budget === "10k_50k" || lead.budget === "50k_plus";
  const soon = lead.timeline === "this_quarter" || lead.timeline === "next_quarter";
  return bigEnough && funded && soon;
}
```

`app/contact-sales/actions.ts`:

```ts
"use server";

import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
import { isQualified, LeadSchema } from "@/lib/lead";

export interface LeadState {
  errors: Record<string, string>;
  values: Record<string, string>;
}

const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];

export async function submitLead(_previous: LeadState, formData: FormData): Promise<LeadState> {
  const values = Object.fromEntries([...formData].map(([key, value]) => [key, String(value)]));
  const parsed = LeadSchema.safeParse(values);
  if (!parsed.success) {
    const { fieldErrors } = z.flattenError(parsed.error);
    const errors = Object.fromEntries(Object.entries(fieldErrors).map(([key, list]) => [key, list?.[0] ?? ""]));
    return { errors, values };
  }

  const stored = new URLSearchParams((await cookies()).get("lead_source")?.value ?? "");
  const source = Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""]));
  const origin = (await headers()).get("origin");
  const qualified = isQualified(parsed.data);

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      ...parsed.data,
      ...source,
      fit: qualified ? "qualified" : "nurture",
      _gotcha: values._gotcha ?? "",
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);
  const result = await response?.json().catch(() => null);

  if (!result?.ok) {
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    const errors = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""]));
    if (!Object.keys(errors).length) errors.form = "We couldn't send that. Email sales@example.com instead.";
    return { errors, values };
  }

  if (!qualified || values._gotcha) redirect("/contact-sales/thanks");
  const { name, email } = parsed.data;
  redirect(`https://cal.com/acme/intro?${new URLSearchParams({ name, email })}`);
}
```

- Zod läuft, bevor irgendetwas deinen Server verlässt, und `z.flattenError` macht aus seinen Problemen eine Meldung pro Feld.
- Der Browser sendet bei jedem Post an eine Server Action einen `Origin`-Header, und die Action reicht ihn weiter: Ein serverseitiges fetch sendet keinen, und Ikiform erkennt daran kostenlose `localhost`-Tests.
- `fit` wird auf dem Server berechnet. Ein Besucher kann sich nicht durch Bearbeiten des Formulars selbst als qualifiziert markieren.
- Beide `redirect()`-Aufrufe stehen außerhalb jedes `try`-Blocks. `redirect()` funktioniert, indem es eine Exception wirft, und ein `catch` würde sie verschlucken.
- Ein ausgefüllter Honeypot wird trotzdem gesendet, sodass Ikiform ihn als Spam speichert, aber er erreicht nie deinen Kalender.

## Das Formular, mit und ohne JavaScript

`app/contact-sales/lead-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type LeadState, submitLead } from "./actions";

const initial: LeadState = { errors: {}, values: {} };

function FieldError({ message }: { message?: string }) {
  return message ? <p role="alert">{message}</p> : null;
}

export function LeadForm() {
  const [state, action, pending] = useActionState(submitLead, initial);
  const { errors, values } = state;

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required defaultValue={values.name} />
      <FieldError message={errors.name} />

      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
      <FieldError message={errors.email} />

      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required defaultValue={values.company} />
      <FieldError message={errors.company} />

      <label htmlFor="company_size">Company size</label>
      <select id="company_size" name="company_size" required defaultValue={values.company_size ?? ""} key={values.company_size}>
        <option value="" disabled>Choose one</option>
        <option value="1_10">1 to 10</option>
        <option value="11_50">11 to 50</option>
        <option value="51_200">51 to 200</option>
        <option value="201_1000">201 to 1,000</option>
        <option value="1000_plus">More than 1,000</option>
      </select>
      <FieldError message={errors.company_size} />

      <label htmlFor="budget">Budget</label>
      <select id="budget" name="budget" required defaultValue={values.budget ?? ""} key={values.budget}>
        <option value="" disabled>Choose one</option>
        <option value="under_10k">Under $10k</option>
        <option value="10k_50k">$10k to $50k</option>
        <option value="50k_plus">More than $50k</option>
        <option value="unsure">Not sure yet</option>
      </select>
      <FieldError message={errors.budget} />

      <label htmlFor="timeline">When do you want to start?</label>
      <select id="timeline" name="timeline" required defaultValue={values.timeline ?? ""} key={values.timeline}>
        <option value="" disabled>Choose one</option>
        <option value="this_quarter">This quarter</option>
        <option value="next_quarter">Next quarter</option>
        <option value="later">Later this year</option>
        <option value="researching">Researching for now</option>
      </select>
      <FieldError message={errors.timeline} />

      <label htmlFor="message">Anything we should know?</label>
      <textarea id="message" name="message" rows={4} defaultValue={values.message} />

      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <FieldError message={errors.form} />
      <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
    </form>
  );
}
```

`app/contact-sales/page.tsx`:

```tsx
import { LeadForm } from "./lead-form";

export default function ContactSales() {
  return (
    <main>
      <h1>Talk to sales</h1>
      <p>Tell us a little about your team. If we look like a fit, you pick a call time on the next screen.</p>
      <LeadForm />
    </main>
  );
}
```

`useActionState` gibt dem Formular sein Pending-Flag und die zurückgegebenen Fehler. Weil die Action eine Server-Funktion ist, sendet das Formular auch vor der Hydration: Next.js rendert das Ergebnis auf dem Server, und der Besucher sieht dieselben Fehler, nur mit vollständigem Seitenladen.

React setzt ein Formular zurück, nachdem seine Action gelaufen ist, also gibt jeder Fehlerpfad `values` zurück, und jeder Input liest es als `defaultValue` wieder ein. Der `key` an jedem `select` erzwingt ein Neu-Mounten mit der zurückgegebenen Auswahl; ein `select` übernimmt ein geändertes `defaultValue` nicht von selbst.

### Honeypot und Menschlichkeitsprüfung

Der versteckte Honeypot wird unverändert weitergeleitet. Die eigene Menschlichkeitsprüfung von Ikiform läuft bei einem Post mit Secret Key nicht, also verifiziere ein Turnstile-Token in der Action vor dem Senden, wenn dieses Formular Bot-Traffic anzieht, wie in [Turnstile in einem Formular](https://ikiform.com/de/guides/spam-protection/cloudflare-turnstile.md).

## Rendering und Deployment

Nichts auf der Kontaktseite liest Request-Daten, also rendert `next build` sie vor. Proxy und Action laufen pro Request. Prüf, wie dein Host sie ausführt:

| Host | Gut zu wissen |
| --- | --- |
| Vercel | Proxy und Server Actions funktionieren ohne Konfiguration. Füge `IKIFORM_SECRET_KEY` in den Umgebungsvariablen des Projekts für Production und Preview hinzu. |
| Netlify | Next.js läuft ohne Konfiguration über den Adapter von Netlify. Setze den Key in den Umgebungsvariablen der Site, beschränkt auf Functions. |
| Cloudflare | Deploye mit dem OpenNext-Adapter für Cloudflare und speichere den Key mit `wrangler secret put`. Prüf in seiner Doku die aktuelle Unterstützung für Proxy, bevor du dich auf das Cookie verlässt. |

## Prüfen, ob es funktioniert

Posts aus `next 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 `next dev` und öffne `/pricing?utm_source=test&utm_campaign=launch`. Prüf das Cookie `lead_source` in den Entwicklertools deines Browsers.
2. Klick dich weiter zu `/contact-sales`, lass **Company size** leer und sende ab. Der Fehler erscheint unter dem Feld, und die anderen Werte bleiben.
3. Sende als qualifizierter Lead: 51 bis 200 Personen, $10k bis $50k, dieses Quartal. Du landest auf Cal.com mit ausgefülltem Namen und E-Mail.
4. Sende als Team mit 1 bis 10 Personen. Du landest auf `/contact-sales/thanks`.
5. Öffne **Submissions** im Dashboard. Beide Leads sind da, mit `utm_source`, `landing_page` 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 |
| --- | --- |
| Die UTM-Felder kommen leer an | Das Cookie wurde gesetzt, bevor du die Tags hinzugefügt hast, oder die Proxy-Datei liegt nicht im Projekt-Root (bzw. in `src/`, wenn du eins nutzt). Lösche das Cookie und lade mit den Tags neu. |
| Die Weiterleitung passiert nie, und das Formular zeigt nichts | `redirect()` wurde in `try` aufgerufen. Verschieb es hinter den Block, wie im Beispiel. |
| `422` mit `Unknown field` für `fit` oder `referrer` | Die Felddefinition des Formulars listet es nicht. Füge jede ID aus der Tabelle als optionales Feld hinzu. |
| `402 test_limit_reached` | Der Workspace hat keinen Tarif und hat seine 50 kostenlosen `localhost`-Tests diesen Monat aufgebraucht. Wähle einen Tarif oder nutze **Send test** im Dashboard. |
| Die Selects springen nach einem Fehler auf **Choose one** zurück | Die Prop `key` fehlt, also behält React das zurückgesetzte `select`. Gib jedem einen Key auf Basis seines zurückgegebenen Werts. |

## Fragen

### Kann eine Next.js Server Action auf eine externe URL weiterleiten?

Ja. redirect() aus next/navigation akzeptiert eine absolute URL. In einer Server Action navigiert es auf dem Client, wenn JavaScript geladen ist, und antwortet auf ein vor der Hydration gesendetes Formular mit einem 303. Ruf es außerhalb jedes try-Blocks auf, denn es funktioniert, indem es eine Exception wirft.

### Warum UTM-Parameter in proxy.ts statt auf der Kontaktseite erfassen?

Besucher konvertieren selten auf der Seite, auf der sie landen. Proxy läuft beim ersten Request, speichert Tags und Referrer in einem Cookie, und die Server Action liest es später. Die Kontaktseite bleibt statisch, weil sie die URL nie liest.

### Brauche ich eine Datenbank, um Leads in Next.js zu qualifizieren?

Nein. Die Server Action bewertet den Lead, leitet ihn mit dem Ergebnis in einem fit-Feld an Ikiform weiter und leitet um. Ikiform speichert den Lead, schickt deinem Team eine E-Mail und kann einen Webhook an dein CRM senden.

### 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.
- [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.
- [Lead-Formular mit Astro](https://ikiform.com/de/guides/lead-capture/astro.md): Ein Lead-Formular auf Basis von Astro Actions: Validierung mit Zod, UTMs des ersten Kontakts auf einer statischen Site, eine On-Demand-Kontaktseite und eine Weiterleitung zu deinem Terminplaner.
- [Lead-Formular mit React Router](https://ikiform.com/de/guides/lead-capture/react-router.md): Ein Lead-Formular mit React Router im Framework-Modus: Root-Middleware mit signiertem Cookie für den ersten Kontakt, eine validierende Routen-Action und eine Weiterleitung zu deinem Terminplaner.
- [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.
