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.
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.
- Request dokumen pertama membawa tag kampanye di URL dan referrer dari luar di header.
- Root middleware membiarkan halaman dirender, lalu menambahkan Set-Cookie bertanda tangan berisi sumbernya.
- Dengan JavaScript, Form mengirim ke action lewat data request dan React Router mengikuti redirect dengan pemuatan halaman penuh.
- Tanpa JavaScript, browser mengirim secara native dan mengikuti 303 sendiri.
- Bagaimanapun, kegagalan validasi mengembalikan data dengan status 400 dan halaman merender error di samping nilai yang diketik.
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.
app/
root.tsx
lead-source.server.ts
routes.ts
routes/
contact-sales.tsx
contact-sales.thanks.tsx
react-router.config.ts
.envIKIFORM_SECRET_KEY=ik_sk_your_secret_key
COOKIE_SECRET=a-long-random-stringLalu tambahkan field di tab Fields formulir. Ikiform menjalankan pemeriksaan yang sama lagi dan menolak key yang tidak dikenalnya.
| ID field | Tipe di Ikiform | Diatur oleh |
|---|---|---|
name, email, company | Teks, email, teks | Pengunjung |
company_size | Select (pilihan): 1_10, 11_50, 51_200, 201_1000, 1000_plus | Pengunjung |
budget | Select (pilihan): under_10k, 10k_50k, 50k_plus, unsure | Pengunjung |
timeline | Select (pilihan): this_quarter, next_quarter, later, researching | Pengunjung |
message | Textarea, opsional | Pengunjung |
utm_source, utm_medium, utm_campaign, referrer, landing_page | Teks, opsional | Server Anda, dari kunjungan pertama |
fit | Select: qualified, nurture | Server Anda, setelah validasi |
IKIFORM_SECRET_KEY di environment variable khusus server.Cookie first-touch bertanda tangan
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;
}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.tsmenjauhkan modul, danCOOKIE_SECRET, dari bundle klien. leadSource.parsemengembalikannulluntuk 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
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
Origindari browser diteruskan, karena fetch di sisi server tidak mengirimnya dan Ikiform mendeteksi ujilocalhostgratis 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, danURLSearchParamsmeng-encode-nya.valuesdi-spread setelahsource, 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.
| Host | Yang perlu diketahui |
|---|---|
| Vercel | Tambahkan preset @vercel/react-router di react-router.config.ts. Atur kedua secret di environment variable proyek. |
| Netlify | Tambahkan @netlify/vite-plugin-react-router. Atur kedua secret di pengaturan situs. |
| Cloudflare | Gunakan @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.
- Jalankan
react-router dev, buka/?utm_source=test&utm_campaign=launch, dan temukan cookielead_sourcebertanda tangan di dev tools. - Edit nilai cookie secara manual dan kirim lead. Field UTM tiba kosong, karena tanda tangannya tidak lagi cocok.
- Matikan JavaScript, biarkan Timeline kosong, lalu kirim. Halaman dimuat ulang dengan error dan nilai-nilai Anda.
- 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.
- Buka Submissions di dashboard dan periksa
utm_campaign,referrer, danfit. - 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
| Masalah | Yang harus dilakukan |
|---|---|
middleware tidak berpengaruh | Di 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 dilarang | ssr: false diatur. Aktifkan kembali SSR untuk aplikasi; prerendering path tertentu tetap bisa berjalan bersamanya. |
| Cookie tidak pernah tersimpan | COOKIE_SECRET kosong atau berubah di setiap deploy, jadi setiap tanda tangan gagal. Atur nilai yang tetap. |
422 validation_failed | Kiriman tidak cocok dengan definisi field formulir. error.fields menyebutkan setiap field dan alasannya. Samakan name input dengan ID field. |
402 test_limit_reached | Workspace belum punya paket dan sudah memakai 50 uji gratis dari localhost bulan ini. Pilih paket, atau pakai Send test di dashboard. |
Pertanyaan
Bisa. redirect() menerima URL absolut. Form post native mengikuti header Location sendiri, dan dengan JavaScript React Router melakukan navigasi dokumen penuh ke origin lain.
Panggil next() untuk mendapatkan respons, lalu tambahkan header Set-Cookie yang dibuat dengan cookie.serialize() dari createCookie. Ekspor middleware dari route, seperti root.tsx, sebagai Route.MiddlewareFunction[].
Ya. Form merender elemen form biasa, action berjalan pada kiriman native, dan mengembalikan data() dengan error atau redirect() bekerja sama seperti dengan JavaScript.
Tidak, tidak ada paket gratis. Pengujian gratis: Anda bisa mengirim 50 kiriman uji per bulan dari localhost sebelum membayar. Untuk go live, Pro mulai $6/bln untuk 500 kiriman, atau Anda bisa bayar sekali untuk 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa.
Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.