Formular zur Lead-Erfassung

Formular zur Lead-Erfassung7 Min. Lesezeit

Qualifizierendes Lead-Formular mit React Router bauen

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.

Preet SutharGründer, Ikiform

Halte UTM-Tags und Referrer mit Root-Middleware beim ersten Dokument-Request fest, in einem signierten Cookie aus createCookie. Validiere den Lead in der action der Route, leite ihn mit der Attribution an dein Formular-Backend weiter und gib für qualifizierte Leads redirect() zu deinem Terminplaner zurück, für den Rest data() mit Feldfehlern. <Form> sendet mit oder ohne JavaScript, also bedient dieselbe Action beides.

Der React-Router-Tab der Hauptanleitung leitet einen Lead aus einer Routen-Action weiter und rendert eine Dankeszeile. Diese Seite baut die Vertriebsversion im Framework-Modus: drei qualifizierende Fragen, auf dem Server geprüft, die erste Kampagne, die den Besucher gebracht hat, und eine Weiterleitung in deinen Kalender für passende Leads.

Was React Router ändert

Die Daten-APIs von React Router wurden um einfache HTML-Formulare herum entworfen. <Form> sendet mit oder ohne JavaScript auf dieselbe Weise an die action der Route, und was die Action zurückgibt, data() mit Fehlern oder redirect(), funktioniert für beides. Mit JavaScript folgt React Router einem Redirect auf einen anderen Origin mit vollständigem Dokument-Laden. Den Server-Pfad schreibst du einmal.

Zwei weitere Teile passen zu dieser Aufgabe. Middleware, seit React Router 8 standardmäßig aktiv, läuft bei jedem Dokument-Request, sodass die Root-Route den ersten Kontakt festhalten kann. Und createCookie signiert seinen Wert mit deinem Secret, sodass ein Besucher seine eigene Attribution nicht umschreiben kann, bevor sie dein CRM erreicht.

Dieselbe React-Router-Action bedient einen Lead mit und ohne JavaScripterster Besuchmit JavaScriptohne JavaScriptDokument-GET?utm_campaign=...Root-Middlewareawait next(), dannSet-CookiesigniertesCookielead_source<Form>lädt .dataActionvalidieren,senden,entscheidenredirect()Dokument-Laden zuCal.com<form>nativer POSTActiongleiche Funktion303 LocationBrowser folgtSeite rendertneuFehler und WertecreateCookiePOST303POST303data(..., 400)Dieselbe React-Router-Action bedient einen Lead mit und ohne JavaScripterster Besuchmit JavaScriptohne JavaScriptDokument-GET?utm_campaign=...Root-Middlewareawait next(), dannSet-CookiesigniertesCookielead_source<Form>lädt .dataActionvalidieren, senden,entscheidenredirect()Dokument-Laden zuCal.com<form>nativer POSTActiongleiche Funktion303 LocationBrowser folgtSeiterendertneuFehler undWertecreateCookiePOST303POST303data(..., 400)
  1. Der erste Dokument-Request trägt die Kampagnen-Tags in der URL und den externen Referrer im Header.
  2. Root-Middleware lässt die Seite rendern und hängt dann ein signiertes Set-Cookie mit der Quelle an.
  3. Mit JavaScript sendet Form über einen Daten-Request an die Action, und React Router folgt dem Redirect mit vollständigem Seitenladen.
  4. Ohne JavaScript sendet der Browser nativ und folgt dem 303 selbst.
  5. In beiden Fällen gibt ein Validierungsfehler Daten mit Status 400 zurück, und die Seite rendert die Fehler neben den eingegebenen Werten.
Eine Action, zwei Transportwege. Die Middleware läuft nur bei Dokument-Requests, und genau dort passiert der erste Kontakt.

Projekt-Setup

Starte mit einer App im Framework-Modus auf React Router 8. Füge zwei reine Server-Variablen hinzu: den Secret Key von Ikiform und ein Secret zum Signieren des Cookies. Senden von einem Server behandelt das Anlegen des Keys.

Dateien
app/
  root.tsx
  lead-source.server.ts
  routes.ts
  routes/
    contact-sales.tsx
    contact-sales.thanks.tsx
react-router.config.ts
.env
.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
COOKIE_SECRET=a-long-random-string

Füge dann die Felder im Tab Fields des Formulars hinzu. Ikiform führt dieselben Prüfungen erneut aus und lehnt Keys ab, die es nicht kennt.

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.

Ein signiertes Cookie für den ersten Kontakt

app/lead-source.server.ts
import { createCookie } from "react-router";

export interface LeadSource {
  utm_source: string;
  utm_medium: string;
  utm_campaign: string;
  referrer: string;
  landing_page: string;
}

export const leadSource = createCookie("lead_source", {
  httpOnly: true,
  sameSite: "lax",
  path: "/",
  maxAge: 60 * 60 * 24 * 30,
  secrets: [process.env.COOKIE_SECRET ?? ""],
});

export async function firstTouch(request: Request): Promise<LeadSource | null> {
  const url = new URL(request.url);
  const referer = request.headers.get("referer") ?? "";
  const from = URL.canParse(referer) ? new URL(referer) : null;
  const tag = (key: string) => url.searchParams.get(key)?.slice(0, 100) ?? "";
  const source: LeadSource = {
    utm_source: tag("utm_source"),
    utm_medium: tag("utm_medium"),
    utm_campaign: tag("utm_campaign"),
    referrer: from && from.host !== url.host ? from.href.slice(0, 300) : "",
    landing_page: url.pathname.slice(0, 200),
  };
  const hasSignal = source.referrer || source.utm_source || source.utm_medium || source.utm_campaign;
  return hasSignal ? source : null;
}
app/root.tsx
import { leadSource, firstTouch } from "./lead-source.server";
import type { Route } from "./+types/root";

const recordFirstTouch: Route.MiddlewareFunction = async ({ request }, next) => {
  const response = await next();
  if (request.method !== "GET") return response;
  if (await leadSource.parse(request.headers.get("Cookie"))) return response;
  const source = await firstTouch(request);
  if (source) response.headers.append("Set-Cookie", await leadSource.serialize(source));
  return response;
};

export const middleware: Route.MiddlewareFunction[] = [recordFirstTouch];
  • Das Suffix .server.ts hält das Modul und COOKIE_SECRET aus dem Client-Bundle heraus.
  • leadSource.parse gibt null zurück, wenn das Cookie fehlt oder seine Signatur nicht passt, sodass ein manipulierter Wert ignoriert und nicht vertraut wird.
  • Die Middleware ruft zuerst next() auf und fügt dann nur einen Header hinzu. Laut Doku soll man Status lesen und Header an der Server-Response setzen, ihren Body aber nicht anfassen.
  • Server-Middleware läuft bei clientseitigen Navigationen nur, wenn sie .data laden. Das ist in Ordnung: Tags kommen beim ersten Dokument-Request an.

Die Routen-Action

app/routes/contact-sales.tsx
import { data, Form, redirect, useNavigation } from "react-router";
import { type LeadSource, leadSource } from "../lead-source.server";
import type { Route } from "./+types/contact-sales";

const CHOICES = {
  company_size: ["1_10", "11_50", "51_200", "201_1000", "1000_plus"],
  budget: ["under_10k", "10k_50k", "50k_plus", "unsure"],
  timeline: ["this_quarter", "next_quarter", "later", "researching"],
};

function validate(values: Record<string, string>) {
  const errors: Record<string, string> = {};
  if (!values.name) errors.name = "Tell us who to reply to.";
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) errors.email = "Enter a work email we can reply to.";
  if (!values.company) errors.company = "Which company is this for?";
  for (const [key, allowed] of Object.entries(CHOICES)) {
    if (!allowed.includes(values[key])) errors[key] = "Pick one of the options.";
  }
  if (values.message.length > 5000) errors.message = "Keep it under 5,000 characters.";
  return errors;
}

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const keys = ["name", "email", "company", "company_size", "budget", "timeline", "message", "_gotcha"];
  const values = Object.fromEntries(keys.map((key) => [key, String(form.get(key) ?? "").trim()]));
  const errors = validate(values);
  if (Object.keys(errors).length) return data({ errors, values }, { status: 400 });

  const source: Partial<LeadSource> = (await leadSource.parse(request.headers.get("Cookie"))) ?? {};
  const qualified =
    values.company_size !== "1_10" &&
    ["10k_50k", "50k_plus"].includes(values.budget) &&
    ["this_quarter", "next_quarter"].includes(values.timeline);
  const origin = request.headers.get("origin");

  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({ ...source, ...values, fit: qualified ? "qualified" : "nurture" }),
  });
  const result = await response.json().catch(() => null);
  if (!result?.ok) {
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    const failed = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""]));
    if (!Object.keys(failed).length) failed.form = "We couldn't send that. Email sales@example.com instead.";
    return data({ errors: failed, values }, { status: response.status });
  }

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

export default function ContactSales({ actionData }: Route.ComponentProps) {
  const pending = useNavigation().state === "submitting";
  const errors: Record<string, string | undefined> = actionData?.errors ?? {};
  const values: Record<string, string | undefined> = actionData?.values ?? {};
  const error = (key: string) => (errors[key] ? <p role="alert">{errors[key]}</p> : null);

  return (
    <Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required defaultValue={values.name} />
      {error("name")}
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
      {error("email")}
      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required defaultValue={values.company} />
      {error("company")}
      <label htmlFor="company_size">Company size</label>
      <select id="company_size" name="company_size" required defaultValue={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>
      {error("company_size")}
      <label htmlFor="budget">Budget</label>
      <select id="budget" name="budget" required defaultValue={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>
      {error("budget")}
      <label htmlFor="timeline">When do you want to start?</label>
      <select id="timeline" name="timeline" required defaultValue={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>
      {error("timeline")}
      <label htmlFor="message">Anything we should know?</label>
      <textarea id="message" name="message" rows={4} defaultValue={values.message} />
      {error("message")}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error("form")}
      <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
    </Form>
  );
}
  • Die Validierung ist eine kleine Funktion ohne Abhängigkeit. Nutzt du Zod schon, setz es ein; die Form der Action bleibt gleich.
  • data({ errors, values }, { status: 400 }) sendet einen echten Fehlerstatus, was React Router auch davon abhält, Loader nach einem fehlgeschlagenen Post neu zu validieren.
  • Der Origin-Header des Browsers wird weitergereicht, weil ein serverseitiges fetch keinen sendet und Ikiform daran kostenlose localhost-Tests erkennt.
  • redirect() akzeptiert absolute URLs. Die Doku warnt davor, Nutzereingaben in Redirect-Zielen ungeprüft zu lassen; hier kommt nur der Query-String vom Besucher, und URLSearchParams kodiert ihn.
  • values wird nach source gespreadet, aber jeder Key ist verschieden, also kann das Cookie kein Formularfeld überschreiben.

UI für Pending und Fehler

useNavigation().state ist submitting, während die Action läuft, was den Button deaktiviert. Inputs lesen defaultValue aus actionData.values, sodass ein nativer Post, der mit Fehlern zurückkommt, trotzdem alles zeigt, was der Besucher eingetippt hat. Mit JavaScript wird das Formular gar nicht zurückgesetzt.

Honeypot und Menschlichkeitsprüfung

Der Honeypot reist mit den anderen Werten, Ikiform speichert einen ausgefüllten als Spam, und die Action schickt ihn auf die Dankes-Route. Der Secret Key überspringt die Menschlichkeitsprüfung von Ikiform, also verifiziere bei Bedarf ein Turnstile-Token in der Action, wie in Turnstile in einem Formular.

Rendering und Deployment

Actions brauchen einen Server zur Laufzeit. Mit ssr: false in react-router.config.ts lehnt der Build jede Route mit action ab. In prerender gelistete Pfade werden zu statischen Dateien, und ein Host, der diese Dateien ausliefert, führt nie die Middleware aus, also lass deine Kampagnen-Landingpages serverseitig rendern.

HostGut zu wissen
VercelFüge das Preset @vercel/react-router in react-router.config.ts hinzu. Setze beide Secrets in den Umgebungsvariablen des Projekts.
NetlifyFüge @netlify/vite-plugin-react-router hinzu. Setze beide Secrets in den Einstellungen der Site.
CloudflareNutze @cloudflare/vite-plugin; React Router 8 hat seinen eigenen Cloudflare-Dev-Proxy entfernt. Speichere beide Secrets mit wrangler secret put und lies sie so, wie deine Worker-Vorlage env bereitstellt.

Prüfen, ob es funktioniert

Posts vom Dev-Server 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 react-router dev, öffne /?utm_source=test&utm_campaign=launch und such das signierte Cookie lead_source in den Entwicklertools.
  2. Bearbeite den Wert des Cookies von Hand und sende einen Lead ab. Die UTM-Felder kommen leer an, weil die Signatur nicht mehr passt.
  3. Schalte JavaScript aus, lass Timeline leer und sende ab. Die Seite lädt mit dem Fehler und deinen Werten neu.
  4. Sende als qualifizierter Lead: 51 bis 200 Personen, mehr als $50k, nächstes 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
middleware hat keine WirkungIn React Router 7 war es hinter dem Flag future.v8_middleware. Aktiviere es oder aktualisiere auf 8, wo es Standard ist.
Der Build scheitert mit verbotenen action-Funktionenssr: false ist gesetzt. Schalte SSR für die App wieder ein; das Vorrendern einzelner Pfade funktioniert daneben weiterhin.
Das Cookie bleibt nie bestehenCOOKIE_SECRET ist leer oder ändert sich bei jedem Deploy, also schlägt jede Signatur fehl. Setze einen stabilen Wert.
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.
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.

Fragen

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