Formulir lead capture7 menit baca
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.
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 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.
- Dengan JavaScript, halaman mengirim objek lead reaktif sebagai JSON dengan $fetch.
- Server route menolak kiriman dari origin lain, memvalidasi dengan Zod, dan meneruskan lead bersama atribusinya.
- Route menjawab JSON berisi URL berikutnya, dan navigateTo berpindah ke Cal.com atau halaman terima kasih.
- Kegagalan validasi dilempar dengan createError, dan halaman membaca pesan field dari data error.
- Tanpa JavaScript, browser mengirim formulir dalam format URL-encoded ke route yang sama.
- Route menjalankan pemeriksaan yang sama dan menjawab dengan 303: ke penjadwal, halaman terima kasih, atau kembali ke formulir dengan nama field yang gagal.
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 membahas key dan formulirnya.
app/
pages/
contact-sales.vue
contact-sales/thanks.vue
server/
middleware/first-touch.ts
api/lead.post.ts
utils/lead.ts
nuxt.config.ts
.envexport 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 |
IKIFORM_SECRET_KEY di environment variable khusus server.Server middleware untuk first touch
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/middlewaresebelum setiap request dan mengharapkannya tidak mengembalikan apa pun. Mengembalikan nilai akan mengakhiri request. - Pemeriksaan
acceptmembatasi cookie ke pemuatan halaman, jadi panggilan API dan aset melewatinya. getCookie,setCookie, dan helper h3 lainnya di-import otomatis diserver/.- Halaman yang di-prerender dengan
routeRulesataunuxt generatedisajikan sebagai file, jadi middleware tidak pernah melihat kunjungan itu. Biarkan landing page kampanye dirender di server.
Satu route, dua jawaban
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");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/utilsdi-import otomatis di server route, itulah sebabnyaLeadSchemadanisQualifiedtidak perlu di-import. - Header
Originbrowser yang sama melakukan dua tugas: memblokir kiriman lintas situs, dan diteruskan ke Ikiform, karena fetch di sisi server tidak mengirimnya dan Ikiform mendeteksi ujilocalhostgratis dari header itu. createErrormenaruh pesan field didata, dan respons error Nuxt membungkusnya lagi, jadi klien membacaerror.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
<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.
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.
- Jalankan
nuxt dev, buka/?utm_source=test&utm_campaign=launch, dan periksa cookielead_sourcedi dev tools. - Di
/contact-sales, biarkan Timeline kosong lalu kirim. Error tampil dan jawaban lainnya tetap ada. - Matikan JavaScript dan kirim dengan cara yang sama. Anda kembali ke
/contact-sales?invalid=timelinedengan field yang ditandai. - 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.
- Buka Submissions di dashboard dan periksa
utm_campaign,referrer, 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 |
|---|---|
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
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.
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 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.
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.