Formular zur Lead-Erfassung

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.

Preet SutharGründer, Ikiform

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.

Wie ein Lead in Next.js vom ersten Anzeigenklick bis zum gebuchten Termin gelangterster BesuchabsendenAnzeige oder Link?utm_source=...proxy.tssetztlead_source-Cookiestatische Seiteaus dem CacheServer ActionZod, liest das CookieIkiformLead mit UTMs undEignungredirect()Cal.com, vorausgefülltAction-StateFehler undeingegebene WerteGETnext()JSONok422 oder Zod-FehlerWie ein Lead in Next.js vom ersten Anzeigenklick bis zum gebuchten Termin gelangterster BesuchabsendenAnzeige oderLink?utm_source=...proxy.tssetztlead_source-Cookiestatische Seiteaus dem CacheServer ActionZod, liest dasCookieIkiformLead mit UTMs undEignungredirect()Cal.com,vorausgefülltAction-StateFehler undeingegebeneWerteGETnext()JSONok422 oder Zod-Fehler
  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 behandelt Key und Formular-Setup im Detail.

Dateien
app/
  contact-sales/
    page.tsx
    actions.ts
    lead-form.tsx
    thanks/page.tsx
lib/
  lead.ts
proxy.ts
.env.local
.env.local
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-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.

Attribution des ersten Kontakts in proxy.ts

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.
  • 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
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
"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
"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
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:

HostGut zu wissen
VercelProxy und Server Actions funktionieren ohne Konfiguration. Füge IKIFORM_SECRET_KEY in den Umgebungsvariablen des Projekts für Production und Preview hinzu.
NetlifyNext.js läuft ohne Konfiguration über den Adapter von Netlify. Setze den Key in den Umgebungsvariablen der Site, beschränkt auf Functions.
CloudflareDeploye 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.

  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

ProblemWas zu tun ist
Die UTM-Felder kommen leer anDas 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 nichtsredirect() wurde in try aufgerufen. Verschieb es hinter den Block, wie im Beispiel.
422 mit Unknown field für fit oder referrerDie Felddefinition des Formulars listet es nicht. Füge jede ID aus der Tabelle als optionales Feld hinzu.
402 test_limit_reachedDer 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ückDie Prop key fehlt, also behält React das zurückgesetzte select. Gib jedem einen Key auf Basis seines zurückgegebenen Werts.

Fragen

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