Formular zur Lead-Erfassung8 Min. Lesezeit
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.
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 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.
- Der Besucher kommt über einen Kampagnenlink mit UTM-Tags oder über den Link einer anderen Site.
- proxy.ts speichert die Tags, den Referrer und den Einstiegspfad einmalig in einem httpOnly-Cookie.
- Preis- und Kontaktseite bleiben statisch und kommen aus dem Cache.
- Beim Absenden validiert die Server Action die Felder mit Zod und liest das Cookie.
- Sie sendet den Lead, die Attribution und eine berechnete Eignung mit dem Secret Key an Ikiform.
- Ein qualifizierter Lead wird mit ausgefülltem Namen und E-Mail zum Terminplaner weitergeleitet; andere gehen auf die Dankeseite.
- Ein Validierungsfehler kommt als Action-State zurück, mit jedem Wert, den der Besucher eingetippt hat.
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 behandelt Key und Formular-Setup im Detail.
app/
contact-sales/
page.tsx
actions.ts
lead-form.tsx
thanks/page.tsx
lib/
lead.ts
proxy.ts
.env.localIKIFORM_SECRET_KEY=ik_sk_your_secret_keyFü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 |
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Attribution des ersten Kontakts in proxy.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.
URLSearchParamsliefert 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.
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;
}"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.flattenErrormacht 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 kostenloselocalhost-Tests. fitwird auf dem Server berechnet. Ein Besucher kann sich nicht durch Bearbeiten des Formulars selbst als qualifiziert markieren.- Beide
redirect()-Aufrufe stehen außerhalb jedestry-Blocks.redirect()funktioniert, indem es eine Exception wirft, und eincatchwü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
"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>
);
}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.
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.
- Starte
next devund öffne/pricing?utm_source=test&utm_campaign=launch. Prüf das Cookielead_sourcein den Entwicklertools deines Browsers. - Klick dich weiter zu
/contact-sales, lass Company size leer und sende ab. Der Fehler erscheint unter dem Feld, und die anderen Werte bleiben. - 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.
- Sende als Team mit 1 bis 10 Personen. Du landest auf
/contact-sales/thanks. - Öffne Submissions im Dashboard. Beide Leads sind da, mit
utm_source,landing_pageundfit. - 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
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.
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.
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.
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.