# 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.

Terbit 2026-10-25 · https://ikiform.com/id/guides/lead-capture/react-router

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](https://ikiform.com/id/guides/lead-capture.md#form) 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.

Diagram: Action React Router yang sama melayani lead dengan dan tanpa JavaScript

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](https://ikiform.com/id/docs/guides/server.md) membahas cara membuat key.

`file`:

```text
app/
  root.tsx
  lead-source.server.ts
  routes.ts
  routes/
    contact-sales.tsx
    contact-sales.thanks.tsx
react-router.config.ts
.env
```

`.env`:

```bash
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 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 |

> 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](https://ikiform.com/id/docs/rate-limits.md) 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`:

```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`:

```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`:

```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](https://ikiform.com/id/guides/spam-protection/cloudflare-turnstile.md).

## 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](https://ikiform.com/id/pricing).

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

| 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

### Bisakah action React Router me-redirect ke domain lain?

Bisa. redirect() menerima URL absolut. Form post native mengikuti header Location sendiri, dan dengan JavaScript React Router melakukan navigasi dokumen penuh ke origin lain.

### Bagaimana cara mengatur cookie dari middleware React Router?

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[].

### Apakah formulir lead React Router bekerja tanpa JavaScript?

Ya. Form merender elemen form biasa, action berjalan pada kiriman native, dan mengembalikan data() dengan error atau redirect() bekerja sama seperti dengan JavaScript.

### Apakah Ikiform gratis?

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.

## Panduan lainnya

- [Formulir lead capture](https://ikiform.com/id/guides/lead-capture.md): Formulir lead capture di situs Anda sendiri yang mengirimi Anda email dengan reply-to, memberi tahu chat tim, dan mengisi CRM, agar Anda menjawab selagi lead masih ada.
- [Formulir lead TanStack Start](https://ikiform.com/id/guides/lead-capture/tanstack-start.md): Formulir lead TanStack Start: global request middleware untuk UTM first-touch, server function dengan Zod, dan redirect yang dilempar ke penjadwal Anda.
- [Formulir lead SvelteKit](https://ikiform.com/id/guides/lead-capture/sveltekit.md): Formulir lead SvelteKit: hook handle mencatat UTM first-touch, form action memvalidasi dengan Valibot, dan redirect() mengarahkan lead yang lolos kualifikasi ke kalender.
- [Formulir permintaan demo](https://ikiform.com/id/guides/lead-capture/demo-request.md): Formulir pesan demo yang menyimpan lead, lalu mengarahkan pengunjung ke Cal.com atau Calendly dengan nama dan email yang sudah terisi.
- [Formulir permintaan penawaran](https://ikiform.com/id/guides/lead-capture/quote-request.md): Formulir minta penawaran yang mengumpulkan brief sebagai tautan bersama alih-alih unggahan, memberi tenggat pada setiap permintaan, dan menjauhkan lead yang penuh tautan dari spam.
- [Formulir permintaan konsultasi](https://ikiform.com/id/guides/lead-capture/consultation-request.md): Formulir permintaan konsultasi untuk akuntan, pengacara, coach, dan klinik yang menanyakan cara dan waktu menghubungi, dan menyimpan detail sensitif untuk panggilannya.
