# Buat formulir lead dengan kualifikasi di TanStack Start

> Formulir lead TanStack Start: global request middleware untuk UTM first-touch, server function dengan Zod, dan redirect yang dilempar ke penjadwal Anda.

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

Catat tag UTM dan referrer pada request halaman pertama dengan global request middleware di `src/start.ts`, dan simpan di cookie httpOnly. Validasi lead di handler `createServerFn` dengan Zod, teruskan bersama atribusinya ke backend formulir Anda, dan lempar `redirect({ href })` untuk mengarahkan lead yang lolos kualifikasi ke penjadwal Anda. Panggil server function lewat `useServerFn` agar redirect diikuti untuk Anda.

[Tab TanStack Start di panduan utama](https://ikiform.com/id/guides/lead-capture.md#form) mengirim lead lewat server function dan menampilkan baris terima kasih. Halaman ini membangun versi untuk sales: tiga pertanyaan kualifikasi yang divalidasi di server, kampanye yang membawa pengunjung, dan redirect langsung ke kalender Anda saat lead-nya cocok.

## Apa yang diubah TanStack Start

Start tidak punya konvensi middleware berbasis file. Apa pun yang harus berjalan di setiap request ditaruh di `src/start.ts` sebagai global request middleware, yang berjalan sebelum SSR, server route, maupun server function. Di sanalah first touch dicatat.

Masalahnya: Start memasang proteksi CSRF untuk server function secara otomatis hanya jika `src/start.ts` tidak ada. Begitu Anda membuat file itu untuk atribusi, proteksi itu menjadi tanggung jawab Anda dan Anda harus menambahkan `createCsrfMiddleware()` sendiri. Jika lupa, endpoint lead Anda menerima kiriman lintas situs.

Diagram: Bagaimana lead bergerak melalui middleware dan server function TanStack Start

1. Setiap request melewati middleware CSRF lebih dulu; middleware ini hanya memeriksa panggilan server function.
2. Pada GET yang belum punya cookie, firstTouch membaca tag UTM dan referrer dari luar lalu menyimpannya.
3. Halaman dirender seperti biasa.
4. Saat submit, useServerFn mengirim FormData ke server function.
5. Validator mengubahnya menjadi objek biasa dan handler menjalankan Zod padanya.
6. Lead yang valid dikirim ke Ikiform bersama atribusi, fit, dan Origin pengunjung.
7. Handler melempar redirect ke Cal.com atau route terima kasih; useServerFn mengikutinya. Error kembali sebagai data.

Menulis src/start.ts menggantikan request middleware default Start, jadi CSRF harus ditambahkan kembali secara manual.

## Setup proyek

Mulai dari proyek TanStack Start dengan React dan instal `zod`. Taruh secret key di `.env` sebagai `IKIFORM_SECRET_KEY`; server function membacanya dari `process.env`, dan key tidak pernah sampai ke bundle klien. Tidak ada halaman khusus Start di dokumentasi; [mengirim dari server](https://ikiform.com/id/docs/guides/server.md) membahas key dan endpoint-nya.

`file`:

```text
src/
  start.ts
  lib/lead.ts
  routes/
    contact-sales.tsx
    contact-sales.thanks.tsx
vite.config.ts
.env
```

Definisikan field di tab **Fields** formulir, agar Ikiform menolak apa pun yang tidak cocok, termasuk key atribusi yang tidak Anda rencanakan.

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

## Global middleware untuk first touch

`src/start.ts`:

```ts
import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";

const TAGS = ["utm_source", "utm_medium", "utm_campaign"];

const csrf = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" });

const firstTouch = createMiddleware().server(({ request, next }) => {
  if (request.method !== "GET" || getCookie("lead_source")) return next();

  const url = new URL(request.url);
  const referer = request.headers.get("referer") ?? "";
  const from = URL.canParse(referer) ? new URL(referer) : null;
  const external = from && from.host !== url.host ? from.href.slice(0, 300) : "";
  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))) {
    source.set("referrer", external);
    source.set("landing_page", url.pathname.slice(0, 200));
    setCookie("lead_source", source.toString(), {
      httpOnly: true,
      sameSite: "lax",
      path: "/",
      maxAge: 60 * 60 * 24 * 30,
    });
  }
  return next();
});

export const startInstance = createStart(() => ({
  requestMiddleware: [csrf, firstTouch],
}));
```

- Filter menjaga pemeriksaan CSRF tetap pada server function, seperti yang disarankan dokumentasi Start untuk penggunaan global. Pemeriksaan membandingkan `Sec-Fetch-Site`, `Origin`, atau `Referer` dengan origin request.
- `firstTouch` melewati semua selain GET, jadi kiriman server function tidak pernah menyentuh cookie.
- `setCookie` dan `getCookie` berasal dari `@tanstack/react-start/server` dan hanya berjalan di server.
- Cookie yang diatur selama satu request belum bisa dibaca `getCookie` sampai request berikutnya. Itu tidak masalah di sini: lead dikirim pada request yang lebih belakangan.

## Server function

`src/lib/lead.ts`:

```ts
import { redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getCookie, getRequestHeader } from "@tanstack/react-start/server";
import { z } from "zod";

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(),
  _gotcha: z.string().optional(),
});

const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];
const FIT = { size: ["1_10"], budget: ["10k_50k", "50k_plus"], timeline: ["this_quarter", "next_quarter"] };

export type LeadResult = { errors: Record<string, string> };

export const submitLead = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return Object.fromEntries([...data].map(([key, value]) => [key, String(value)]));
  })
  .handler(async ({ data }): Promise<LeadResult> => {
    const parsed = LeadSchema.safeParse(data);
    if (!parsed.success) {
      const { fieldErrors } = z.flattenError(parsed.error);
      return { errors: Object.fromEntries(Object.entries(fieldErrors).map(([key, list]) => [key, list?.[0] ?? ""])) };
    }
    const lead = parsed.data;
    const stored = new URLSearchParams(getCookie("lead_source") ?? "");
    const qualified =
      !FIT.size.includes(lead.company_size) && FIT.budget.includes(lead.budget) && FIT.timeline.includes(lead.timeline);
    const origin = getRequestHeader("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({
        ...lead,
        ...Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""])),
        fit: qualified ? "qualified" : "nurture",
      }),
    });
    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] ?? ""]));
      return { errors: Object.keys(errors).length ? errors : { form: result?.error?.message ?? "Try again." } };
    }

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

- `.validator()` hanya mengubah bentuk `FormData`; Zod berjalan di handler dengan `safeParse`, jadi error field kembali sebagai data alih-alih error yang dilempar yang tidak bisa dibaca komponen per field.
- `fit` ditentukan di server. Mengedit formulir tidak bisa menaikkan status lead.
- `getRequestHeader("origin")` diteruskan, karena fetch di sisi server tidak mengirim `Origin` dan Ikiform mendeteksi uji `localhost` gratis dari header itu.
- `redirect({ href })` adalah bentuk eksternal; `redirect({ to })` tetap di dalam aplikasi. Keduanya dilempar.

## Route

`src/routes/contact-sales.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { submitLead } from "../lib/lead";

export const Route = createFileRoute("/contact-sales")({ component: ContactSales });

const SELECTS = {
  company_size: ["Company size", [["1_10", "1 to 10"], ["11_50", "11 to 50"], ["51_200", "51 to 200"], ["201_1000", "201 to 1,000"], ["1000_plus", "More than 1,000"]]],
  budget: ["Budget", [["under_10k", "Under $10k"], ["10k_50k", "$10k to $50k"], ["50k_plus", "More than $50k"], ["unsure", "Not sure yet"]]],
  timeline: ["When do you want to start?", [["this_quarter", "This quarter"], ["next_quarter", "Next quarter"], ["later", "Later this year"], ["researching", "Researching for now"]]],
} as const;

function ContactSales() {
  const send = useServerFn(submitLead);
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    try {
      const result = await send({ data: new FormData(event.currentTarget) });
      setErrors(result?.errors ?? {});
    } catch {
      setErrors({ form: "We couldn't send that. Email sales@example.com instead." });
    } finally {
      setPending(false);
    }
  }

  const error = (key: string) => (errors[key] ? <p role="alert">{errors[key]}</p> : null);

  return (
    <form method="post" action={submitLead.url} encType="multipart/form-data" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      {error("name")}
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      {error("email")}
      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required />
      {error("company")}
      {Object.entries(SELECTS).map(([id, [label, options]]) => (
        <div key={id}>
          <label htmlFor={id}>{label}</label>
          <select id={id} name={id} required defaultValue="">
            <option value="" disabled>Choose one</option>
            {options.map(([value, text]) => (
              <option key={value} value={value}>{text}</option>
            ))}
          </select>
          {error(id)}
        </div>
      ))}
      <label htmlFor="message">Anything we should know?</label>
      <textarea id="message" name="message" rows={4} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error("form")}
      <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
    </form>
  );
}
```

Handler submit mencegah kiriman default dan memanggil server function lewat `useServerFn`, yang mengikuti redirect yang dilempar, termasuk ke situs lain. Karena formulir tidak pernah direset, setiap nilai tetap di tempatnya selagi pengunjung memperbaiki sebuah field.

Atribut `action={submitLead.url}` adalah jalur tanpa JavaScript. Start mendokumentasikan `.url` untuk formulir HTML, tetapi browser kemudian menampilkan respons mentah server function alih-alih halaman ini. Uji dengan JavaScript mati dan putuskan apakah itu bisa diterima, atau taruh tautan email biasa untuk sales di bawah formulir.

### Honeypot dan pemeriksaan manusia

Honeypot yang terisi tetap dikirim, jadi Ikiform menyimpannya sebagai spam, dan handler mengarahkannya ke route terima kasih alih-alih kalender Anda. Secret key melewati pemeriksaan manusia Ikiform; tambahkan pemeriksaan Turnstile ke handler jika bot menemukan formulir, mengikuti [Turnstile di formulir](https://ikiform.com/id/guides/spam-protection/cloudflare-turnstile.md).

## Rendering dan deploy

Route dirender di server secara default, dan middleware berjalan di setiap request tersebut. Jika Anda melakukan prerender halaman marketing ke file statis, CDN menyajikannya tanpa menjalankan middleware, jadi atribusi harus dimulai di halaman yang dirender per request.

| Host | Yang perlu diketahui |
| --- | --- |
| Vercel | Tambahkan plugin `nitro/vite` lalu deploy. Atur `IKIFORM_SECRET_KEY` di environment variable proyek. |
| Netlify | Tambahkan `@netlify/vite-plugin-tanstack-start` ke `vite.config.ts`. Atur key di pengaturan situs. |
| Cloudflare | Tambahkan `@cloudflare/vite-plugin` dan deploy dengan Wrangler. Simpan key dengan `wrangler secret put` dan aktifkan `nodejs_compat` agar `process.env` memuatnya. |

## 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 dev server, buka `/?utm_source=test&utm_campaign=launch`, dan pastikan cookie `lead_source` bersifat httpOnly di dev tools.
2. Di `/contact-sales`, pilih **Under $10k** dan biarkan **Company** kosong. Error perusahaan tampil dan setiap nilai lainnya tetap ada.
3. Kirim sebagai lead yang lolos kualifikasi: 201 sampai 1.000 orang, $10rb sampai $50rb, kuartal ini. Browser berpindah ke Cal.com dengan nama dan email terisi.
4. Kirim sebagai tim berisi 1 sampai 10 orang. Anda mendarat di `/contact-sales/thanks`.
5. Buka **Submissions** di dashboard dan periksa `utm_source`, `landing_page`, 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 |
| --- | --- |
| `403` dari server function Anda sendiri setelah menambahkan `src/start.ts` | Pemeriksaan CSRF menolak panggilannya. Request dari origin publik yang berbeda, seperti di balik proxy, butuh `createCsrfMiddleware({ origin })`. |
| Kiriman lintas situs sampai ke server function | `src/start.ts` ada tanpa `createCsrfMiddleware()`. Tambahkan, seperti di contoh. |
| Halaman tidak berpindah setelah submit yang lolos kualifikasi | Server function dipanggil langsung, bukan lewat `useServerFn`, jadi tidak ada yang mengikuti redirect yang dilempar. |
| Error tentang meng-import kode server di klien | `getCookie` dipanggil di luar server function atau middleware. Jaga tetap di dalam `.handler()` atau `.server()`. |
| `422 validation_failed` | Kiriman tidak cocok dengan definisi field formulir. `error.fields` menyebutkan setiap field dan alasannya. Samakan `name` input dengan ID field. |

## Pertanyaan

### Bagaimana cara me-redirect ke URL eksternal dari server function TanStack Start?

Lempar redirect({ href: 'https://...' }) dari @tanstack/react-router di dalam handler, dan panggil server function lewat useServerFn di komponen. useServerFn mengikuti redirect, termasuk ke situs lain.

### Di mana middleware ditaruh di TanStack Start?

Global request middleware ditaruh di src/start.ts, dikembalikan dari createStart sebagai requestMiddleware. Middleware ini berjalan sebelum SSR, server route, dan server function. Membuat file itu berarti menambahkan createCsrfMiddleware() sendiri.

### Bagaimana cara memvalidasi FormData di server function TanStack Start?

Gunakan .validator() untuk memeriksa bahwa input berupa FormData dan mengubahnya menjadi objek biasa, lalu jalankan safeParse dari Zod di handler agar Anda bisa mengembalikan satu pesan per field.

### 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 React Router](https://ikiform.com/id/guides/lead-capture/react-router.md): Formulir lead React Router framework mode: root middleware dengan cookie first-touch bertanda tangan, route action yang memvalidasi, dan redirect ke penjadwal Anda.
- [Formulir lead Next.js](https://ikiform.com/id/guides/lead-capture/nextjs.md): Formulir lead App Router Next.js yang memvalidasi dengan Zod di server action, menyimpan UTM first-touch di cookie, dan mengirim lead yang lolos kualifikasi ke kalender Anda.
- [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.
