Formulir lead capture

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.

Preet SutharPendiri, Ikiform

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.

Bagaimana lead bergerak melalui middleware dan server function TanStack Startsetiap requestkirimcsrfhanya serverfunctionfirstTouchGET, aturlead_sourcehalamanSSRatau serverfunctionuseServerFnkirimFormDatacreateServerFnvalidator,lalu ZodIkiformsecret key,Originlemparredirecthref: Cal.com{ errors }tampil dibawah setiapfieldnext()next()POSTJSONokZod atau 422Bagaimana lead bergerak melalui middleware dan server function TanStack Startsetiap requestkirimcsrfhanya serverfunctionfirstTouchGET, aturlead_sourcehalaman SSRatau serverfunctionuseServerFnkirim FormDatacreateServerFnvalidator, lalu ZodIkiformsecret key, Originlempar redirecthref: Cal.com{ errors}tampil dibawahsetiapfieldnext()next()POSTJSONokZod atau 422
  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 membahas key dan endpoint-nya.

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

Global middleware untuk first touch

src/start.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
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
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.

HostYang perlu diketahui
VercelTambahkan plugin nitro/vite lalu deploy. Atur IKIFORM_SECRET_KEY di environment variable proyek.
NetlifyTambahkan @netlify/vite-plugin-tanstack-start ke vite.config.ts. Atur key di pengaturan situs.
CloudflareTambahkan @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.

  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

MasalahYang harus dilakukan
403 dari server function Anda sendiri setelah menambahkan src/start.tsPemeriksaan CSRF menolak panggilannya. Request dari origin publik yang berbeda, seperti di balik proxy, butuh createCsrfMiddleware({ origin }).
Kiriman lintas situs sampai ke server functionsrc/start.ts ada tanpa createCsrfMiddleware(). Tambahkan, seperti di contoh.
Halaman tidak berpindah setelah submit yang lolos kualifikasiServer function dipanggil langsung, bukan lewat useServerFn, jadi tidak ada yang mengikuti redirect yang dilempar.
Error tentang meng-import kode server di kliengetCookie dipanggil di luar server function atau middleware. Jaga tetap di dalam .handler() atau .server().
422 validation_failedKiriman tidak cocok dengan definisi field formulir. error.fields menyebutkan setiap field dan alasannya. Samakan name input dengan ID field.

Pertanyaan

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