Formulir lead capture8 menit baca
Buat formulir lead dengan kualifikasi di Next.js
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.
Tangkap tag kampanye dan referrer di proxy.ts pada kunjungan pertama, dan simpan di cookie. Validasi lead dengan Zod di server action, beri skor berdasarkan ukuran perusahaan, anggaran, dan jadwal, lalu teruskan bersama atribusinya ke backend formulir Anda. Kemudian panggil redirect() untuk mengarahkan lead yang lolos kualifikasi ke penjadwal Anda dan yang lain ke halaman terima kasih.
Tab Next.js di panduan utama meneruskan lead dan menampilkan baris terima kasih. Halaman ini membangun versi untuk sales: tiga pertanyaan kualifikasi yang diperiksa di server Anda, data kampanye yang bertahan meski pengunjung berpindah-pindah halaman sebelum konversi, dan penyerahan langsung ke kalender Anda untuk lead yang layak ditelepon.
Apa yang diubah Next.js
Dua fitur Next.js melakukan sebagian besar pekerjaan. proxy.ts, file yang sebelum Next.js 16 bernama middleware.ts, berjalan sebelum setiap request halaman, bahkan untuk halaman statis. Itu menjadikannya tempat yang tepat untuk membaca ?utm_source= dan header Referer pada kunjungan pertama, karena saat pengunjung sampai di Talk to sales, keduanya sudah hilang dari URL. Server action kemudian membaca cookie, jadi halaman kontak sendiri bisa tetap statis.
Yang kedua adalah redirect(). Di server action, fungsi ini menerima URL absolut, jadi lead yang lolos kualifikasi mendarat di Cal.com tanpa kode klien. Dengan JavaScript termuat, Next.js bernavigasi di klien; sebelum hydration, ia menjawab form post dengan 303.
- Pengunjung datang dari tautan kampanye dengan tag UTM atau dari tautan di situs lain.
- proxy.ts menyimpan tag, referrer, dan path landing di cookie httpOnly, sekali saja.
- Halaman harga dan kontak tetap statis dan disajikan dari cache.
- Saat submit, server action memvalidasi field dengan Zod dan membaca cookie.
- Action mengirim lead, atribusi, dan fit yang dihitung ke Ikiform dengan secret key.
- Lead yang lolos kualifikasi di-redirect ke penjadwal dengan nama dan email terisi; yang lain ke halaman terima kasih.
- Error validasi kembali sebagai state action, dengan setiap nilai yang diketik pengunjung.
Setup proyek
Mulai dari proyek App Router apa pun di Next.js 16. Instal zod, buat formulir di dashboard dan secret key dengan scope submissions:write di API keys. Halaman dokumentasi Next.js membahas key dan penyiapan formulir secara rinci.
app/
contact-sales/
page.tsx
actions.ts
lead-form.tsx
thanks/page.tsx
lib/
lead.ts
proxy.ts
.env.localIKIFORM_SECRET_KEY=ik_sk_your_secret_keyLalu tambahkan field di tab Fields formulir, agar Ikiform juga menegakkannya. Tanpa definisi, Ikiform menyimpan apa pun yang Anda kirim; dengan definisi, field yang tidak dikenal menjadi 422, yang memang Anda inginkan begitu atribusi ada di payload.
| 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.Atribusi first-touch di proxy.ts
import { type NextRequest, NextResponse } from "next/server";
const TAGS = ["utm_source", "utm_medium", "utm_campaign"];
export function proxy(request: NextRequest) {
const response = NextResponse.next();
if (request.cookies.has("lead_source")) return response;
const url = request.nextUrl;
const referer = request.headers.get("referer") ?? "";
const from = URL.canParse(referer) ? new URL(referer) : null;
const external = from && from.host !== url.host ? from.href : "";
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))) return response;
source.set("referrer", external.slice(0, 300));
source.set("landing_page", url.pathname.slice(0, 200));
response.cookies.set("lead_source", source.toString(), {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
});
return response;
}
export const config = {
matcher: [
{
source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
};- Cookie diatur sekali dan tidak pernah ditimpa, jadi mencatat kampanye pertama, bukan klik internal terakhir. Hapus pengecekan
hasjika Anda lebih suka last touch. - Referrer dari host Anda sendiri diabaikan. Tanpa itu, setiap tampilan halaman setelah yang pertama akan dihitung sebagai rujukan dari diri sendiri.
- Matcher melewati file statis dan prefetch router. Prefetch membawa halaman Anda sendiri sebagai referrer dan jika tidak dilewati akan menjalankan proxy untuk setiap tautan di layar.
URLSearchParamsmemberi nilai cookie yang ringkas dan parser yang aman. Action hanya membaca lima key yang dikenalnya, jadi pengunjung yang mengedit cookie tidak bisa menambahkan field.
Validasi, penilaian, dan penyerahan
Schema ada di modulnya sendiri agar action dan route API di masa depan bisa memakainya bersama. Aturan fit sengaja dibuat sederhana: minimal sebelas orang, anggaran $10rb atau lebih, dan tanggal mulai dalam dua kuartal.
import { z } from "zod";
export 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(),
});
export type Lead = z.infer<typeof LeadSchema>;
export function isQualified(lead: Lead) {
const bigEnough = lead.company_size !== "1_10";
const funded = lead.budget === "10k_50k" || lead.budget === "50k_plus";
const soon = lead.timeline === "this_quarter" || lead.timeline === "next_quarter";
return bigEnough && funded && soon;
}"use server";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
import { isQualified, LeadSchema } from "@/lib/lead";
export interface LeadState {
errors: Record<string, string>;
values: Record<string, string>;
}
const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];
export async function submitLead(_previous: LeadState, formData: FormData): Promise<LeadState> {
const values = Object.fromEntries([...formData].map(([key, value]) => [key, String(value)]));
const parsed = LeadSchema.safeParse(values);
if (!parsed.success) {
const { fieldErrors } = z.flattenError(parsed.error);
const errors = Object.fromEntries(Object.entries(fieldErrors).map(([key, list]) => [key, list?.[0] ?? ""]));
return { errors, values };
}
const stored = new URLSearchParams((await cookies()).get("lead_source")?.value ?? "");
const source = Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""]));
const origin = (await headers()).get("origin");
const qualified = isQualified(parsed.data);
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({
...parsed.data,
...source,
fit: qualified ? "qualified" : "nurture",
_gotcha: values._gotcha ?? "",
}),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
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] ?? ""]));
if (!Object.keys(errors).length) errors.form = "We couldn't send that. Email sales@example.com instead.";
return { errors, values };
}
if (!qualified || values._gotcha) redirect("/contact-sales/thanks");
const { name, email } = parsed.data;
redirect(`https://cal.com/acme/intro?${new URLSearchParams({ name, email })}`);
}- Zod berjalan sebelum apa pun keluar dari server Anda, dan
z.flattenErrormengubah masalahnya menjadi satu pesan per field. - Browser mengirim header
Origindi setiap kiriman server action, dan action meneruskannya: fetch di sisi server tidak mengirimnya, dan Ikiform mendeteksi ujilocalhostgratis dari header itu. fitdihitung di server. Pengunjung tidak bisa menandai dirinya lolos kualifikasi dengan mengedit formulir.- Kedua panggilan
redirect()berada di luar bloktry.redirect()bekerja dengan melempar exception, dancatchakan menelannya. - Honeypot yang terisi tetap dikirim, jadi Ikiform mencatatnya sebagai spam, tetapi tidak pernah sampai ke kalender Anda.
Formulir, dengan dan tanpa JavaScript
"use client";
import { useActionState } from "react";
import { type LeadState, submitLead } from "./actions";
const initial: LeadState = { errors: {}, values: {} };
function FieldError({ message }: { message?: string }) {
return message ? <p role="alert">{message}</p> : null;
}
export function LeadForm() {
const [state, action, pending] = useActionState(submitLead, initial);
const { errors, values } = state;
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required defaultValue={values.name} />
<FieldError message={errors.name} />
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
<FieldError message={errors.email} />
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" required defaultValue={values.company} />
<FieldError message={errors.company} />
<label htmlFor="company_size">Company size</label>
<select id="company_size" name="company_size" required defaultValue={values.company_size ?? ""} key={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>
<FieldError message={errors.company_size} />
<label htmlFor="budget">Budget</label>
<select id="budget" name="budget" required defaultValue={values.budget ?? ""} key={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>
<FieldError message={errors.budget} />
<label htmlFor="timeline">When do you want to start?</label>
<select id="timeline" name="timeline" required defaultValue={values.timeline ?? ""} key={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>
<FieldError message={errors.timeline} />
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} defaultValue={values.message} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<FieldError message={errors.form} />
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
);
}import { LeadForm } from "./lead-form";
export default function ContactSales() {
return (
<main>
<h1>Talk to sales</h1>
<p>Tell us a little about your team. If we look like a fit, you pick a call time on the next screen.</p>
<LeadForm />
</main>
);
}useActionState memberi formulir flag pending dan error yang dikembalikan. Karena action adalah server function, formulir juga mengirim sebelum hydration: Next.js merender hasilnya di server dan pengunjung melihat error yang sama, hanya dengan pemuatan halaman penuh.
React mereset formulir setelah action-nya berjalan, jadi setiap jalur error mengembalikan values dan setiap input membacanya kembali sebagai defaultValue. key di setiap select memaksanya di-mount ulang dengan pilihan yang dikembalikan; select tidak membaca defaultValue yang berubah dengan sendirinya.
Honeypot dan pemeriksaan manusia
Honeypot tersembunyi diteruskan apa adanya. Pemeriksaan manusia milik Ikiform tidak berjalan pada kiriman dengan secret key, jadi jika formulir ini menarik trafik bot, verifikasi token Turnstile di action sebelum mengirim, seperti di Turnstile di formulir.
Rendering dan deploy
Tidak ada di halaman kontak yang membaca data request, jadi next build melakukan prerender. Proxy dan action berjalan per request. Periksa cara host Anda menjalankannya:
| Host | Yang perlu diketahui |
|---|---|
| Vercel | Proxy dan server action bekerja tanpa konfigurasi. Tambahkan IKIFORM_SECRET_KEY di environment variable proyek untuk Production dan Preview. |
| Netlify | Next.js berjalan lewat adapter Netlify tanpa konfigurasi. Atur key di environment variable situs, dengan scope functions. |
| Cloudflare | Deploy dengan adapter OpenNext Cloudflare dan simpan key dengan wrangler secret put. Periksa dokumentasinya untuk dukungan proxy terbaru sebelum Anda mengandalkan cookie. |
Pastikan berfungsi
Kiriman dari next dev 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
next devdan buka/pricing?utm_source=test&utm_campaign=launch. Periksa cookielead_sourcedi dev tools browser Anda. - Klik ke
/contact-sales, biarkan Company size kosong, lalu kirim. Error tampil di bawah field dan nilai lainnya tetap ada. - Kirim sebagai lead yang lolos kualifikasi: 51 sampai 200 orang, $10rb sampai $50rb, kuartal ini. Anda mendarat di 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. Kedua lead ada di sana dengan
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 |
|---|---|
| Field UTM tiba dalam keadaan kosong | Cookie sudah diatur sebelum Anda menambahkan tag, atau file proxy tidak berada di root proyek (atau di src/ jika Anda memakainya). Hapus cookie dan muat ulang dengan tag. |
| Redirect tidak pernah terjadi dan formulir tidak menampilkan apa-apa | redirect() dipanggil di dalam try. Pindahkan setelah blok itu, seperti di contoh. |
422 dengan Unknown field untuk fit atau referrer | Definisi field formulir tidak mencantumkannya. Tambahkan setiap ID dari tabel sebagai field opsional. |
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. |
| Select kembali ke Choose one setelah error | Prop key tidak ada, jadi React mempertahankan select yang sudah direset. Beri key pada setiap select berdasarkan nilai yang dikembalikan. |
Pertanyaan
Bisa. redirect() dari next/navigation menerima URL absolut. Di server action, fungsi ini bernavigasi di klien saat JavaScript termuat dan menjawab dengan 303 untuk formulir yang dikirim sebelum hydration. Panggil di luar blok try, karena fungsi ini bekerja dengan melempar exception.
Pengunjung jarang konversi di halaman tempat mereka mendarat. Proxy berjalan di request pertama, menyimpan tag dan referrer di cookie, dan server action membacanya nanti. Halaman kontak tetap statis karena tidak pernah membaca URL.
Tidak. Server action memberi skor lead, meneruskannya ke Ikiform dengan hasilnya di field fit, lalu me-redirect. Ikiform menyimpan lead, mengirim email ke tim Anda, dan bisa mengirim webhook ke CRM Anda.
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.