Formulir lead capture

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.

Preet SutharPendiri, Ikiform

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.

Satu server route Nuxt menjawab formulir yang ter-hydrate dan form post biasadengan JavaScripttanpa JavaScript$fetchbody JSONlead.post.tscek origin,Zod{ next }jawaban JSONnavigateToexternal: trueerror.data.data.fieldstampil dibawah setiapfield<formaction>urlencodedlead.post.tsvalidasi samasendRedirect303Cal.com atau?invalid=POSTcreateError(400)POSTSatu server route Nuxt menjawab formulir yang ter-hydrate dan form post biasadengan JavaScripttanpa JavaScript$fetchbody JSONlead.post.tscek origin, Zod{ next }jawaban JSONnavigateToexternal: trueerror.data.data.fieldstampil dibawahsetiapfield<form action>urlencodedlead.post.tsvalidasi samasendRedirect303Cal.com atau?invalid=POSTPOSTcreateError(400)
  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 membahas key dan formulirnya.

file
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
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});

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

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.

Server middleware untuk first touch

server/middleware/first-touch.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
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
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
<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

HostYang perlu diketahui
VercelNitro mendeteksi Vercel dan membangun function. Atur NUXT_IKIFORM_SECRET_KEY di environment variable.
NetlifyNitro juga mendeteksi Netlify. Atur variabel di pengaturan situs.
CloudflareBuild 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.

  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

MasalahYang harus dilakukan
navigateTo melempar error saat lead lolos kualifikasiexternal: true tidak ada. navigateTo menolak URL absolut ke situs lain tanpanya.
Key kosong di produksiVariabel harus bernama NUXT_IKIFORM_SECRET_KEY, dan route harus memanggil useRuntimeConfig(event) agar dibaca saat runtime.
403 Cross-site post di balik proxyProxy mengubah Host yang dilihat route. Teruskan host aslinya, atau bandingkan origin dengan domain publik Anda.
Cookie diatur pada panggilan API atau gambarPemeriksaan accept dihapus. Batasi middleware ke pemuatan halaman.
422 validation_failedKiriman tidak cocok dengan definisi field formulir. error.fields menyebutkan setiap field dan alasannya. Samakan name input dengan ID field.

Pertanyaan

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