Formulir lead capture

Formulir lead capture7 menit baca

Buat formulir lead dengan kualifikasi di React Router

Formulir lead React Router framework mode: root middleware dengan cookie first-touch bertanda tangan, route action yang memvalidasi, dan redirect ke penjadwal Anda.

Preet SutharPendiri, Ikiform

Catat tag UTM dan referrer dengan root middleware pada request dokumen pertama, di cookie bertanda tangan dari createCookie. Validasi lead di action route, teruskan bersama atribusinya ke backend formulir Anda, dan kembalikan redirect() ke penjadwal Anda untuk lead yang lolos kualifikasi atau data() dengan error field untuk sisanya. <Form> mengirim dengan atau tanpa JavaScript, jadi action yang sama melayani keduanya.

Tab React Router di panduan utama meneruskan lead dari route action dan merender baris terima kasih. Halaman ini membangun versi untuk sales dalam framework mode: tiga pertanyaan kualifikasi yang diperiksa di server, kampanye pertama yang membawa pengunjung, dan redirect ke kalender Anda untuk lead yang cocok.

Apa yang diubah React Router

API data React Router dirancang di sekitar formulir HTML biasa. <Form> mengirim ke action route dengan cara yang sama dengan atau tanpa JavaScript, dan apa pun yang dikembalikan action, data() dengan error atau redirect(), bekerja untuk keduanya. Dengan JavaScript, React Router mengikuti redirect ke origin lain dengan pemuatan dokumen penuh. Anda cukup menulis jalur server sekali.

Dua bagian lagi cocok untuk tugas ini. Middleware, aktif secara default sejak React Router 8, berjalan di setiap request dokumen, jadi root route bisa mencatat first touch. Dan createCookie menandatangani nilainya dengan secret Anda, jadi pengunjung tidak bisa menulis ulang atribusinya sendiri sebelum sampai ke CRM Anda.

Action React Router yang sama melayani lead dengan dan tanpa JavaScriptkunjungan pertamadengan JavaScripttanpa JavaScriptGET dokumen?utm_campaign=...rootmiddlewareawait next(), laluSet-Cookiecookiebertandatanganlead_source<Form>mengambil .dataactionvalidasi, kirim,putuskanredirect()muat dokumen keCal.com<form>POST nativeactionfunction yang sama303 Locationbrowser mengikutihalamandirender ulangerror dan nilaicreateCookiePOST303POST303data(..., 400)Action React Router yang sama melayani lead dengan dan tanpa JavaScriptkunjungan pertamadengan JavaScripttanpa JavaScriptGET dokumen?utm_campaign=...root middlewareawait next(), laluSet-Cookiecookie bertandatanganlead_source<Form>mengambil .dataactionvalidasi, kirim,putuskanredirect()muat dokumen keCal.com<form>POST nativeactionfunction yang sama303 Locationbrowser mengikutihalamandirenderulangerror dannilaicreateCookiePOST303POST303data(..., 400)
  1. Request dokumen pertama membawa tag kampanye di URL dan referrer dari luar di header.
  2. Root middleware membiarkan halaman dirender, lalu menambahkan Set-Cookie bertanda tangan berisi sumbernya.
  3. Dengan JavaScript, Form mengirim ke action lewat data request dan React Router mengikuti redirect dengan pemuatan halaman penuh.
  4. Tanpa JavaScript, browser mengirim secara native dan mengikuti 303 sendiri.
  5. Bagaimanapun, kegagalan validasi mengembalikan data dengan status 400 dan halaman merender error di samping nilai yang diketik.
Satu action, dua jalur. Middleware hanya berjalan pada request dokumen, tempat first touch terjadi.

Setup proyek

Mulai dari aplikasi framework mode di React Router 8. Tambahkan dua variabel khusus server: secret key Ikiform dan secret untuk menandatangani cookie. Mengirim dari server membahas cara membuat key.

file
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

Lalu tambahkan field di tab Fields formulir. Ikiform menjalankan pemeriksaan yang sama lagi dan menolak key yang tidak dikenalnya.

ID fieldTipe di IkiformDiatur oleh
name, email, companyTeks, email, teksPengunjung
company_sizeSelect (pilihan): 1_10, 11_50, 51_200, 201_1000, 1000_plusPengunjung
budgetSelect (pilihan): under_10k, 10k_50k, 50k_plus, unsurePengunjung
timelineSelect (pilihan): this_quarter, next_quarter, later, researchingPengunjung
messageTextarea, opsionalPengunjung
utm_source, utm_medium, utm_campaign, referrer, landing_pageTeks, opsionalServer Anda, dari kunjungan pertama
fitSelect: qualified, nurtureServer Anda, setelah validasi
Versi sisi server mengirim dengan secret key, sehingga Ikiform melewati Allowed domains dan pemeriksaan manusianya. Semua kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sebagai satu. Pilih rate limit Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan IKIFORM_SECRET_KEY di environment variable khusus server.

Cookie first-touch bertanda tangan

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];
  • Akhiran .server.ts menjauhkan modul, dan COOKIE_SECRET, dari bundle klien.
  • leadSource.parse mengembalikan null untuk cookie yang tidak ada dan untuk cookie yang tanda tangannya tidak cocok, jadi nilai yang dirusak diabaikan, bukan dipercaya.
  • Middleware memanggil next() lebih dulu, lalu hanya menambahkan header. Dokumentasi menyarankan membaca status dan mengatur header pada respons server, bukan menyentuh body-nya.
  • Server middleware berjalan pada navigasi sisi klien hanya saat navigasi itu mengambil .data. Itu tidak masalah: tag tiba di request dokumen pertama.

Route 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>
  );
}
  • Validasi adalah fungsi kecil tanpa dependensi. Ganti dengan Zod jika Anda sudah memakainya; bentuk action tetap sama.
  • data({ errors, values }, { status: 400 }) mengirim status error sungguhan, yang juga mencegah React Router melakukan revalidasi loader pada kiriman yang gagal.
  • Header Origin dari browser diteruskan, karena fetch di sisi server tidak mengirimnya dan Ikiform mendeteksi uji localhost gratis dari header itu.
  • redirect() menerima URL absolut. Dokumentasi memperingatkan untuk memvalidasi input pengguna di target redirect; di sini hanya query string yang berasal dari pengunjung, dan URLSearchParams meng-encode-nya.
  • values di-spread setelah source, tetapi setiap key berbeda, jadi cookie tidak bisa menimpa field formulir.

UI pending dan error

useNavigation().state bernilai submitting selama action berjalan, yang menonaktifkan tombol. Input membaca defaultValue dari actionData.values, jadi kiriman native yang kembali dengan error tetap menampilkan semua yang diketik pengunjung. Dengan JavaScript, formulir sama sekali tidak direset.

Honeypot dan pemeriksaan manusia

Honeypot ikut terkirim bersama nilai lainnya, Ikiform menyimpan yang terisi sebagai spam, dan action mengarahkannya ke route terima kasih. Secret key melewati pemeriksaan manusia Ikiform, jadi verifikasi token Turnstile di action jika Anda membutuhkannya, seperti di Turnstile di formulir.

Rendering dan deploy

Action butuh server runtime. Dengan ssr: false di react-router.config.ts, build menolak route mana pun yang punya action. Path yang terdaftar di prerender menjadi file statis, dan host yang menyajikan file-file itu tidak pernah menjalankan middleware, jadi biarkan landing page kampanye Anda dirender di server.

HostYang perlu diketahui
VercelTambahkan preset @vercel/react-router di react-router.config.ts. Atur kedua secret di environment variable proyek.
NetlifyTambahkan @netlify/vite-plugin-react-router. Atur kedua secret di pengaturan situs.
CloudflareGunakan @cloudflare/vite-plugin; React Router 8 menghapus dev proxy Cloudflare miliknya sendiri. Simpan kedua secret dengan wrangler secret put dan baca sesuai cara template Worker Anda menyediakan env.

Pastikan berfungsi

Kiriman dari dev server membawa origin localhost. Ikiform tidak punya paket gratis. Pengujian gratis: workspace tanpa paket menerima 50 kiriman uji per bulan dari localhost. Untuk go live, pilih Pro mulai $6/bln untuk 500 kiriman, atau bayar sekali: 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa. Lihat harga.

  1. Jalankan react-router dev, buka /?utm_source=test&utm_campaign=launch, dan temukan cookie lead_source bertanda tangan di dev tools.
  2. Edit nilai cookie secara manual dan kirim lead. Field UTM tiba kosong, karena tanda tangannya tidak lagi cocok.
  3. Matikan JavaScript, biarkan Timeline kosong, lalu kirim. Halaman dimuat ulang dengan error dan nilai-nilai Anda.
  4. Kirim sebagai lead yang lolos kualifikasi: 51 sampai 200 orang, lebih dari $50rb, kuartal depan. Anda mendarat di Cal.com dengan nama dan email terisi.
  5. Buka Submissions di dashboard dan periksa utm_campaign, referrer, dan fit.
  6. Cek inbox setiap penerima notifikasi. Masing-masing mendapat email berisi field, dan Reply terkirim ke pengirim formulir jika formulir punya field email.

Hal yang sering menjebak

MasalahYang harus dilakukan
middleware tidak berpengaruhDi React Router 7, fitur ini ada di balik flag future.v8_middleware. Aktifkan, atau upgrade ke 8 yang menjadikannya default.
Build gagal dengan pesan fungsi action dilarangssr: false diatur. Aktifkan kembali SSR untuk aplikasi; prerendering path tertentu tetap bisa berjalan bersamanya.
Cookie tidak pernah tersimpanCOOKIE_SECRET kosong atau berubah di setiap deploy, jadi setiap tanda tangan gagal. Atur nilai yang tetap.
422 validation_failedKiriman tidak cocok dengan definisi field formulir. error.fields menyebutkan setiap field dan alasannya. Samakan name input dengan ID field.
402 test_limit_reachedWorkspace belum punya paket dan sudah memakai 50 uji gratis dari localhost bulan ini. Pilih paket, atau pakai Send test di dashboard.

Pertanyaan

Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.