Formulir lead capture

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.

Preet SutharPendiri, Ikiform

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.

Perjalanan lead Next.js dari klik iklan pertama hingga panggilan yang dipesankunjungan pertamakirimiklan atau tautan?utm_source=...proxy.tsatur cookielead_sourcehalaman statisdisajikan dari cacheserver actionZod, membaca cookieIkiformlead dengan UTM danfitredirect()Cal.com, sudah terisiaction stateerror dan nilai yangdiketikGETnext()JSONok422 atau error ZodPerjalanan lead Next.js dari klik iklan pertama hingga panggilan yang dipesankunjungan pertamakirimiklan atautautan?utm_source=...proxy.tsatur cookielead_sourcehalaman statisdisajikan daricacheserver actionZod, membaca cookieIkiformlead dengan UTM danfitredirect()Cal.com, sudahterisiactionstateerror dannilai yangdiketikGETnext()JSONok422 atau error Zod
  1. Pengunjung datang dari tautan kampanye dengan tag UTM atau dari tautan di situs lain.
  2. proxy.ts menyimpan tag, referrer, dan path landing di cookie httpOnly, sekali saja.
  3. Halaman harga dan kontak tetap statis dan disajikan dari cache.
  4. Saat submit, server action memvalidasi field dengan Zod dan membaca cookie.
  5. Action mengirim lead, atribusi, dan fit yang dihitung ke Ikiform dengan secret key.
  6. Lead yang lolos kualifikasi di-redirect ke penjadwal dengan nama dan email terisi; yang lain ke halaman terima kasih.
  7. Error validasi kembali sebagai state action, dengan setiap nilai yang diketik pengunjung.
Cookie membawa first touch melintasi halaman, jadi halaman kontak tidak perlu membaca URL.

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.

file
app/
  contact-sales/
    page.tsx
    actions.ts
    lead-form.tsx
    thanks/page.tsx
lib/
  lead.ts
proxy.ts
.env.local
.env.local
IKIFORM_SECRET_KEY=ik_sk_your_secret_key

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

Atribusi first-touch di proxy.ts

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 has jika 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.
  • URLSearchParams memberi 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.

lib/lead.ts
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;
}
app/contact-sales/actions.ts
"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.flattenError mengubah masalahnya menjadi satu pesan per field.
  • Browser mengirim header Origin di setiap kiriman server action, dan action meneruskannya: fetch di sisi server tidak mengirimnya, dan Ikiform mendeteksi uji localhost gratis dari header itu.
  • fit dihitung di server. Pengunjung tidak bisa menandai dirinya lolos kualifikasi dengan mengedit formulir.
  • Kedua panggilan redirect() berada di luar blok try. redirect() bekerja dengan melempar exception, dan catch akan menelannya.
  • Honeypot yang terisi tetap dikirim, jadi Ikiform mencatatnya sebagai spam, tetapi tidak pernah sampai ke kalender Anda.

Formulir, dengan dan tanpa JavaScript

app/contact-sales/lead-form.tsx
"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>
  );
}
app/contact-sales/page.tsx
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:

HostYang perlu diketahui
VercelProxy dan server action bekerja tanpa konfigurasi. Tambahkan IKIFORM_SECRET_KEY di environment variable proyek untuk Production dan Preview.
NetlifyNext.js berjalan lewat adapter Netlify tanpa konfigurasi. Atur key di environment variable situs, dengan scope functions.
CloudflareDeploy 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.

  1. Jalankan next dev dan buka /pricing?utm_source=test&utm_campaign=launch. Periksa cookie lead_source di dev tools browser Anda.
  2. Klik ke /contact-sales, biarkan Company size kosong, lalu kirim. Error tampil di bawah field dan nilai lainnya tetap ada.
  3. 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.
  4. Kirim sebagai tim berisi 1 sampai 10 orang. Anda mendarat di /contact-sales/thanks.
  5. Buka Submissions di dashboard. Kedua lead ada di sana dengan 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
Field UTM tiba dalam keadaan kosongCookie 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-aparedirect() dipanggil di dalam try. Pindahkan setelah blok itu, seperti di contoh.
422 dengan Unknown field untuk fit atau referrerDefinisi field formulir tidak mencantumkannya. Tambahkan setiap ID dari tabel sebagai field opsional.
402 test_limit_reachedWorkspace 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 errorProp key tidak ada, jadi React mempertahankan select yang sudah direset. Beri key pada setiap select berdasarkan nilai yang dikembalikan.

Pertanyaan

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