Formulir lead capture7 menit baca
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.
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 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.
- Setiap request melewati middleware CSRF lebih dulu; middleware ini hanya memeriksa panggilan server function.
- Pada GET yang belum punya cookie, firstTouch membaca tag UTM dan referrer dari luar lalu menyimpannya.
- Halaman dirender seperti biasa.
- Saat submit, useServerFn mengirim FormData ke server function.
- Validator mengubahnya menjadi objek biasa dan handler menjalankan Zod padanya.
- Lead yang valid dikirim ke Ikiform bersama atribusi, fit, dan Origin pengunjung.
- Handler melempar redirect ke Cal.com atau route terima kasih; useServerFn mengikutinya. Error kembali sebagai data.
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 membahas key dan endpoint-nya.
src/
start.ts
lib/lead.ts
routes/
contact-sales.tsx
contact-sales.thanks.tsx
vite.config.ts
.envDefinisikan 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 |
IKIFORM_SECRET_KEY di environment variable khusus server.Global middleware untuk first touch
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, atauRefererdengan origin request. firstTouchmelewati semua selain GET, jadi kiriman server function tidak pernah menyentuh cookie.setCookiedangetCookieberasal dari@tanstack/react-start/serverdan hanya berjalan di server.- Cookie yang diatur selama satu request belum bisa dibaca
getCookiesampai request berikutnya. Itu tidak masalah di sini: lead dikirim pada request yang lebih belakangan.
Server function
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 bentukFormData; Zod berjalan di handler dengansafeParse, jadi error field kembali sebagai data alih-alih error yang dilempar yang tidak bisa dibaca komponen per field.fitditentukan di server. Mengedit formulir tidak bisa menaikkan status lead.getRequestHeader("origin")diteruskan, karena fetch di sisi server tidak mengirimOrigindan Ikiform mendeteksi ujilocalhostgratis dari header itu.redirect({ href })adalah bentuk eksternal;redirect({ to })tetap di dalam aplikasi. Keduanya dilempar.
Route
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.
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.
- Jalankan dev server, buka
/?utm_source=test&utm_campaign=launch, dan pastikan cookielead_sourcebersifat httpOnly di dev tools. - Di
/contact-sales, pilih Under $10k dan biarkan Company kosong. Error perusahaan tampil dan setiap nilai lainnya tetap ada. - 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.
- Kirim sebagai tim berisi 1 sampai 10 orang. Anda mendarat di
/contact-sales/thanks. - Buka Submissions di dashboard dan periksa
utm_source,landing_page, 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 |
|---|---|
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
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.
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.
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.
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.