# Buat formulir lead dengan kualifikasi di Nuxt

> Formulir lead Nuxt: server middleware mencatat UTM first-touch, satu server route tervalidasi Zod melayani fetch dan kiriman biasa, dan lead yang bagus memesan panggilan.

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

Catat tag UTM dan referrer di server middleware Nitro pada request halaman pertama, di cookie httpOnly. Kirim formulir ke satu server route yang memvalidasi dengan Zod, meneruskan lead bersama atribusinya ke backend formulir Anda, dan menjawab JSON untuk `$fetch` atau `303` untuk form post biasa. Di klien, `navigateTo(url, { external: true })` mengarahkan lead yang lolos kualifikasi ke penjadwal Anda.

[Tab Nuxt di panduan utama](https://ikiform.com/id/guides/lead-capture.md#form) mengirim lead lewat server route dengan `$fetch` dan menampilkan baris terima kasih. Halaman ini membangun versi untuk sales: ukuran perusahaan, anggaran, dan jadwal diperiksa server, kampanye di balik setiap lead, penyerahan ke kalender Anda, dan jalur yang tetap bekerja saat JavaScript tidak termuat.

## Apa yang diubah Nuxt

Nuxt tidak punya form action. Formulir mengirim ke server route Nitro seperti endpoint lainnya, sehingga ada dua jalur yang harus dilayani: `$fetch` dengan JSON dari halaman yang sudah ter-hydrate, dan kiriman URL-encoded dari formulir HTML polos. `readBody` dari h3 mem-parsing keduanya, jadi satu route bisa melayani keduanya, asalkan menjawab masing-masing dengan caranya sendiri.

Kemudahan itu ada harganya: route yang menerima body URL-encoded juga menerima form post lintas situs biasa. Dokumentasi h3 memperingatkan hal ini, dan tidak seperti SvelteKit atau Next.js, Nuxt tidak menambahkan pemeriksaan origin untuk Anda. Route memeriksa `Origin` sendiri.

Diagram: Satu server route Nuxt menjawab formulir yang ter-hydrate dan form post biasa

1. Dengan JavaScript, halaman mengirim objek lead reaktif sebagai JSON dengan $fetch.
2. Server route menolak kiriman dari origin lain, memvalidasi dengan Zod, dan meneruskan lead bersama atribusinya.
3. Route menjawab JSON berisi URL berikutnya, dan navigateTo berpindah ke Cal.com atau halaman terima kasih.
4. Kegagalan validasi dilempar dengan createError, dan halaman membaca pesan field dari data error.
5. Tanpa JavaScript, browser mengirim formulir dalam format URL-encoded ke route yang sama.
6. Route menjalankan pemeriksaan yang sama dan menjawab dengan 303: ke penjadwal, halaman terima kasih, atau kembali ke formulir dengan nama field yang gagal.

Content type yang menentukan jawabannya, bukan route-nya. Validasi dan kiriman ke Ikiform cukup ditulis sekali.

## Setup proyek

Mulai dari aplikasi Nuxt 4 dan instal `zod`. Deklarasikan key di `runtimeConfig` dan atur sebagai `NUXT_IKIFORM_SECRET_KEY`; Nuxt memetakan variabel itu saat runtime ketika route membaca `useRuntimeConfig(event)`. [Halaman dokumentasi Nuxt](https://ikiform.com/id/docs/guides/nuxt.md) membahas key dan formulirnya.

`file`:

```text
app/
  pages/
    contact-sales.vue
    contact-sales/thanks.vue
server/
  middleware/first-touch.ts
  api/lead.post.ts
  utils/lead.ts
nuxt.config.ts
.env
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
```

Tambahkan field di tab **Fields** formulir, agar Ikiform mengulang validasi dan menolak key yang tidak dikenal.

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

## Server middleware untuk first touch

`server/middleware/first-touch.ts`:

```ts
const TAGS = ["utm_source", "utm_medium", "utm_campaign"];

export default defineEventHandler((event) => {
  if (event.method !== "GET" || getCookie(event, "lead_source")) return;
  if (!getRequestHeader(event, "accept")?.includes("text/html")) return;

  const url = getRequestURL(event);
  const referer = getRequestHeader(event, "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))) return;

  source.set("referrer", external);
  source.set("landing_page", url.pathname.slice(0, 200));
  setCookie(event, "lead_source", source.toString(), {
    httpOnly: true,
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 24 * 30,
  });
});
```

- Nitro menjalankan file di `server/middleware` sebelum setiap request dan mengharapkannya tidak mengembalikan apa pun. Mengembalikan nilai akan mengakhiri request.
- Pemeriksaan `accept` membatasi cookie ke pemuatan halaman, jadi panggilan API dan aset melewatinya.
- `getCookie`, `setCookie`, dan helper h3 lainnya di-import otomatis di `server/`.
- Halaman yang di-prerender dengan `routeRules` atau `nuxt generate` disajikan sebagai file, jadi middleware tidak pernah melihat kunjungan itu. Biarkan landing page kampanye dirender di server.

## Satu route, dua jawaban

`server/utils/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(),
  _gotcha: z.string().optional(),
});

export type Lead = z.infer<typeof LeadSchema>;

export const isQualified = (lead: Lead) =>
  lead.company_size !== "1_10" &&
  (lead.budget === "10k_50k" || lead.budget === "50k_plus") &&
  (lead.timeline === "this_quarter" || lead.timeline === "next_quarter");
```

`server/api/lead.post.ts`:

```ts
import { z } from "zod";

const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];

export default defineEventHandler(async (event) => {
  const origin = getRequestHeader(event, "origin");
  if (origin && new URL(origin).host !== getRequestHost(event)) {
    throw createError({ status: 403, message: "Posts from other sites aren't accepted." });
  }
  const wantsJson = getRequestHeader(event, "content-type")?.includes("application/json");
  const parsed = LeadSchema.safeParse(await readBody(event));

  if (!parsed.success) {
    const fields = z.flattenError(parsed.error).fieldErrors;
    if (!wantsJson) return sendRedirect(event, `/contact-sales?invalid=${Object.keys(fields).join(",")}`, 303);
    throw createError({ status: 400, message: "Check the highlighted fields.", data: { fields } });
  }

  const lead = parsed.data;
  const stored = new URLSearchParams(getCookie(event, "lead_source") ?? "");
  const qualified = isQualified(lead);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${useRuntimeConfig(event).ikiformSecretKey}`,
      ...(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) {
    if (!wantsJson) return sendRedirect(event, "/contact-sales?failed=1", 303);
    throw createError({
      status: response.status,
      message: result?.error?.message ?? "We couldn't send that. Try again.",
      data: { fields: result?.error?.fields ?? {} },
    });
  }

  const next =
    qualified && !lead._gotcha
      ? `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}`
      : "/contact-sales/thanks";
  return wantsJson ? { next } : sendRedirect(event, next, 303);
});
```

- Ekspor dari `server/utils` di-import otomatis di server route, itulah sebabnya `LeadSchema` dan `isQualified` tidak perlu di-import.
- Header `Origin` browser yang sama melakukan dua tugas: memblokir kiriman lintas situs, dan diteruskan ke Ikiform, karena fetch di sisi server tidak mengirimnya dan Ikiform mendeteksi uji `localhost` gratis dari header itu.
- `createError` menaruh pesan field di `data`, dan respons error Nuxt membungkusnya lagi, jadi klien membaca `error.data.data.fields`.
- Kiriman biasa tidak bisa membawa state kembali tanpa sesi, jadi kegagalan me-redirect dengan nama field yang gagal di query string. Pengunjung mengetik ulang; ini jalur cadangan.

## Halamannya

`app/pages/contact-sales.vue`:

```vue
<script setup lang="ts">
const route = useRoute();
const lead = reactive({ name: "", email: "", company: "", company_size: "", budget: "", timeline: "", message: "", _gotcha: "" });
const errors = ref<Record<string, string[] | undefined>>({});
const failure = ref(route.query.failed ? "We couldn't send that. Email sales@example.com instead." : "");
const sending = ref(false);

for (const key of String(route.query.invalid ?? "").split(",").filter(Boolean)) {
  errors.value[key] = ["Check this field."];
}

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;

async function send() {
  sending.value = true;
  errors.value = {};
  failure.value = "";
  try {
    const { next } = await $fetch<{ next: string }>("/api/lead", { method: "POST", body: lead });
    await navigateTo(next, { external: next.startsWith("https://") });
  } catch (error) {
    const data = (error as { data?: { message?: string; data?: { fields?: Record<string, string[]> } } }).data;
    errors.value = data?.data?.fields ?? {};
    if (!Object.keys(errors.value).length) failure.value = data?.message ?? "We couldn't send that. Try again.";
    sending.value = false;
  }
}
</script>

<template>
  <form action="/api/lead" method="post" @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="lead.name" name="name" autocomplete="name" required />
    <p v-if="errors.name" role="alert">{{ errors.name[0] }}</p>
    <label for="email">Work email</label>
    <input id="email" v-model="lead.email" name="email" type="email" autocomplete="email" required />
    <p v-if="errors.email" role="alert">{{ errors.email[0] }}</p>
    <label for="company">Company</label>
    <input id="company" v-model="lead.company" name="company" autocomplete="organization" required />
    <p v-if="errors.company" role="alert">{{ errors.company[0] }}</p>
    <template v-for="([label, options], id) in selects" :key="id">
      <label :for="id">{{ label }}</label>
      <select :id="id" v-model="lead[id]" :name="id" required>
        <option value="" disabled>Choose one</option>
        <option v-for="(text, value) in options" :key="value" :value="value">{{ text }}</option>
      </select>
      <p v-if="errors[id]" role="alert">{{ errors[id]?.[0] }}</p>
    </template>
    <label for="message">Anything we should know?</label>
    <textarea id="message" v-model="lead.message" name="message" rows="4" />
    <input v-model="lead._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="failure" role="alert">{{ failure }}</p>
    <button :disabled="sending">{{ sending ? "Sending" : "Talk to sales" }}</button>
  </form>
</template>
```

`@submit.prevent` mengambil alih saat Nuxt sudah ter-hydrate, dan objek `lead` reaktif menjaga setiap nilai melewati error. `navigateTo` menolak URL absolut kecuali `external` diatur, jadi flag mengikuti URL: external untuk Cal.com, navigasi klien untuk halaman terima kasih. Sebelum hydration, atribut `action` mengirim ke route yang sama dan halaman membaca `?invalid=` untuk menandai field.

### Honeypot dan pemeriksaan manusia

Honeypot ada di schema dan diteruskan, jadi Ikiform menyimpan yang terisi sebagai spam, dan route mengarahkannya ke halaman terima kasih. Secret key melewati pemeriksaan manusia Ikiform; verifikasi token Turnstile di route jika Anda membutuhkannya, seperti di [Turnstile di formulir](https://ikiform.com/id/guides/spam-protection/cloudflare-turnstile.md).

## Catatan deploy

| Host | Yang perlu diketahui |
| --- | --- |
| Vercel | Nitro mendeteksi Vercel dan membangun function. Atur `NUXT_IKIFORM_SECRET_KEY` di environment variable. |
| Netlify | Nitro juga mendeteksi Netlify. Atur variabel di pengaturan situs. |
| Cloudflare | Build dengan preset `cloudflare_module` dan simpan variabel dengan `wrangler secret put`. Membaca `useRuntimeConfig(event)` dengan event akan mengambilnya saat runtime. |

## Pastikan berfungsi

Kiriman dari `nuxt 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 `nuxt dev`, buka `/?utm_source=test&utm_campaign=launch`, dan periksa cookie `lead_source` di dev tools.
2. Di `/contact-sales`, biarkan **Timeline** kosong lalu kirim. Error tampil dan jawaban lainnya tetap ada.
3. Matikan JavaScript dan kirim dengan cara yang sama. Anda kembali ke `/contact-sales?invalid=timeline` dengan field yang ditandai.
4. Kirim sebagai lead yang lolos kualifikasi: 11 sampai 50 orang, lebih dari $50rb, kuartal ini. Anda mendarat di Cal.com dengan nama dan email terisi.
5. Buka **Submissions** di dashboard dan periksa `utm_campaign`, `referrer`, 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 |
| --- | --- |
| `navigateTo` melempar error saat lead lolos kualifikasi | `external: true` tidak ada. `navigateTo` menolak URL absolut ke situs lain tanpanya. |
| Key kosong di produksi | Variabel harus bernama `NUXT_IKIFORM_SECRET_KEY`, dan route harus memanggil `useRuntimeConfig(event)` agar dibaca saat runtime. |
| `403 Cross-site post` di balik proxy | Proxy mengubah `Host` yang dilihat route. Teruskan host aslinya, atau bandingkan origin dengan domain publik Anda. |
| Cookie diatur pada panggilan API atau gambar | Pemeriksaan `accept` dihapus. Batasi middleware ke pemuatan halaman. |
| `422 validation_failed` | Kiriman tidak cocok dengan definisi field formulir. `error.fields` menyebutkan setiap field dan alasannya. Samakan `name` input dengan ID field. |

## Pertanyaan

### Bagaimana cara me-redirect ke URL eksternal di Nuxt?

Di klien, panggil navigateTo(url, { external: true }); tanpa flag itu, fungsi ini melempar error untuk situs lain. Di server route, sendRedirect(event, url, 303) menjawab form post biasa dengan redirect.

### Bisakah satu server route Nuxt menangani $fetch sekaligus form post biasa?

Bisa. readBody mem-parsing body JSON dan URL-encoded, jadi periksa content type lalu jawab JSON untuk $fetch dan redirect 303 untuk kiriman native. Periksa juga header Origin, karena route kini menerima form post lintas situs.

### Di mana pelacakan UTM sebaiknya ditaruh di Nuxt?

Di file di bawah server/middleware. File itu berjalan sebelum setiap request server, bisa membaca query string dan referrer, dan mengatur cookie httpOnly yang nanti dibaca route lead. File itu tidak berjalan untuk halaman yang di-prerender dan disajikan sebagai file.

### 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 lead SvelteKit](https://ikiform.com/id/guides/lead-capture/sveltekit.md): Formulir lead SvelteKit: hook handle mencatat UTM first-touch, form action memvalidasi dengan Valibot, dan redirect() mengarahkan lead yang lolos kualifikasi ke kalender.
- [Formulir lead Next.js](https://ikiform.com/id/guides/lead-capture/nextjs.md): 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.
- [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 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.
