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

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

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](https://ikiform.com/id/guides/lead-capture.md#form) 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`.

Diagram: Perjalanan lead Next.js dari klik iklan pertama hingga panggilan yang dipesan

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](https://ikiform.com/id/docs/guides/nextjs.md) membahas key dan penyiapan formulir secara rinci.

`file`:

```text
app/
  contact-sales/
    page.tsx
    actions.ts
    lead-form.tsx
    thanks/page.tsx
lib/
  lead.ts
proxy.ts
.env.local
```

`.env.local`:

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

## Atribusi first-touch di proxy.ts

`proxy.ts`:

```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`:

```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`:

```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`:

```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`:

```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](https://ikiform.com/id/guides/spam-protection/cloudflare-turnstile.md).

## 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](https://ikiform.com/id/pricing).

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

| 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

### Bisakah server action Next.js me-redirect ke URL eksternal?

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.

### Mengapa menangkap parameter UTM di proxy.ts alih-alih di halaman kontak?

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.

### Apakah saya butuh database untuk mengkualifikasi lead di Next.js?

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.

### 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 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 lead Astro](https://ikiform.com/id/guides/lead-capture/astro.md): Formulir lead Astro berbasis Actions: validasi Zod, UTM first-touch di situs statis, halaman kontak on demand, dan redirect ke penjadwal Anda.
- [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 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.
