Formulir lead capture

Formulir lead capture7 menit baca

Buat formulir lead dengan kualifikasi di SvelteKit

Formulir lead SvelteKit: hook handle mencatat UTM first-touch, form action memvalidasi dengan Valibot, dan redirect() mengarahkan lead yang lolos kualifikasi ke kalender.

Preet SutharPendiri, Ikiform

Catat tag UTM dan referrer di hook handle pada GET pertama, di cookie httpOnly. Validasi lead di form action dengan Valibot, kembalikan fail() dengan error dan nilai saat tidak lolos, dan teruskan lead yang valid bersama atribusinya ke backend formulir Anda. Lalu panggil redirect(303, url, { external: true }) untuk mengarahkan lead yang lolos kualifikasi ke penjadwal Anda; use:enhance mengikutinya ke situs lain.

Tab SvelteKit di panduan utama meneruskan lead dari form action dan menampilkan baris terima kasih. Halaman ini membangun versi yang diminta tim sales: ukuran perusahaan, anggaran, dan jadwal divalidasi di server, kampanye di balik setiap lead, dan penyerahan ke kalender Anda saat lead-nya cocok.

Apa yang diubah SvelteKit

Form action adalah cara bawaan SvelteKit menangani kiriman, dan tetap berfungsi dengan rapi tanpa JavaScript: tanpa JavaScript browser mengirim dan mengikuti redirect sendiri, dan dengan use:enhance action yang sama berjalan lewat fetch. Hasil redirect ke origin lain kembali ke pemuatan halaman penuh, jadi penyerahan ke Cal.com tidak butuh kode klien tambahan.

SvelteKit juga memberi Anda satu hook, handle, yang membungkus setiap request server. Itu tempat yang tepat untuk mencatat first touch. SvelteKit 3 mengubah dua hal yang disentuh formulir ini: redirect ke situs lain butuh { external: true }, dan secret dideklarasikan di src/env.ts lalu dibaca dari $app/env/private saat runtime.

Apa yang dilakukan SvelteKit dengan setiap jenis hasil actionsetiap GETkirimhook handlebelum adacookie?cookies.setlead_source,httpOnlyresolve(event)merenderhalamanuse:enhancePOST fetchdefaultactionValibot, laluIkiformredirect(303)origin lain:muat penuhprop formerror dan nilai,tidak diresettag atau referrerdata formulirfail(400)Apa yang dilakukan SvelteKit dengan setiap jenis hasil actionsetiap GETkirimhook handlebelum ada cookie?cookies.setlead_source,httpOnlyresolve(event)merender halamanuse:enhancePOST fetchdefault actionValibot, laluIkiformredirect(303)origin lain: muatpenuhpropformerror dannilai,tidakdiresettag atau referrerdata formulirfail(400)
  1. Hook handle melihat setiap GET. Jika belum ada cookie lead_source dan ada tag UTM atau referrer dari luar, hook mengaturnya.
  2. SvelteKit me-resolve request dan merender halaman seperti biasa.
  3. Saat submit, use:enhance mengirim formulir dengan fetch dan mengatur status pending.
  4. Action memvalidasi dengan Valibot, membaca cookie, dan meneruskan lead bersama Origin pengunjung.
  5. Redirect ke Cal.com tidak bisa menjadi navigasi klien, jadi SvelteKit memuatnya sebagai halaman penuh.
  6. Hasil fail memperbarui prop form; update dengan reset false menjaga input sesuai yang diketik.
Tanpa JavaScript, browser melakukan dua hal yang sama sendiri: mengikuti 303 atau merender halaman dengan form.

Setup proyek

Mulai dari proyek SvelteKit 3, instal valibot, dan tambahkan adapter Anda. Taruh secret key di .env sebagai IKIFORM_SECRET_KEY dan deklarasikan di src/env.ts. Key itu privat dan dibaca saat runtime secara default, dan SvelteKit menolak meng-import $app/env/private ke kode klien. Halaman dokumentasi SvelteKit membahas key dan formulirnya.

file
src/
  env.ts
  hooks.server.ts
  routes/
    contact-sales/
      +page.server.ts
      +page.svelte
      thanks/+page.svelte
vite.config.ts
.env
src/env.ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});

Tambahkan field di tab Fields formulir, agar Ikiform mengulang pemeriksaan dan menolak field yang tidak Anda definisikan.

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.

First touch di hook handle

src/hooks.server.ts
import type { Handle } from "@sveltejs/kit/hooks";

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

export const handle: Handle = async ({ event, resolve }) => {
  if (event.request.method === "GET" && !event.cookies.get("lead_source")) {
    const referer = event.request.headers.get("referer") ?? "";
    const from = URL.canParse(referer) ? new URL(referer) : null;
    const external = from && from.host !== event.url.host ? from.href.slice(0, 300) : "";
    const source = new URLSearchParams();
    for (const tag of TAGS) source.set(tag, event.url.searchParams.get(tag)?.slice(0, 100) ?? "");

    if (external || TAGS.some((tag) => source.get(tag))) {
      source.set("referrer", external);
      source.set("landing_page", event.url.pathname.slice(0, 200));
      event.cookies.set("lead_source", source.toString(), {
        path: "/",
        httpOnly: true,
        sameSite: "lax",
        maxAge: 60 * 60 * 24 * 30,
      });
    }
  }
  return resolve(event);
};
  • Hanya request GET yang dihitung, jadi POST milik action sendiri tidak pernah mengatur cookie.
  • SvelteKit 3 mengatur path cookie ke / secara default. Mengaturnya secara eksplisit membuat contoh tetap berjalan di SvelteKit 2, yang mewajibkan path.
  • Referrer dari host Anda sendiri diabaikan, jadi klik internal tidak menggantikan sumber aslinya.
  • Hook tidak berjalan untuk halaman yang di-prerender dan disajikan sebagai file. Biarkan landing page kampanye dirender on demand, atau tag-nya tidak pernah sampai ke hook.

Form action

src/routes/contact-sales/+page.server.ts
import { fail, redirect } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import * as v from "valibot";
import type { Actions } from "./$types";

export const prerender = false;

const Lead = v.object({
  name: v.pipe(v.string(), v.trim(), v.nonEmpty("Tell us who to reply to."), v.maxLength(120)),
  email: v.pipe(v.string(), v.trim(), v.email("Enter a work email we can reply to.")),
  company: v.pipe(v.string(), v.trim(), v.nonEmpty("Which company is this for?"), v.maxLength(200)),
  company_size: v.picklist(["1_10", "11_50", "51_200", "201_1000", "1000_plus"], "Pick a company size."),
  budget: v.picklist(["under_10k", "10k_50k", "50k_plus", "unsure"], "Pick a budget range."),
  timeline: v.picklist(["this_quarter", "next_quarter", "later", "researching"], "Pick a timeline."),
  message: v.pipe(v.string(), v.trim(), v.maxLength(5000, "Keep it under 5,000 characters.")),
  _gotcha: v.string(),
});

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

export const actions = {
  default: async ({ request, cookies, fetch, url }) => {
    const form = await request.formData();
    const values = Object.fromEntries(Object.keys(Lead.entries).map((key) => [key, String(form.get(key) ?? "")]));
    const parsed = v.safeParse(Lead, values);
    if (!parsed.success) {
      const nested = v.flatten<typeof Lead>(parsed.issues).nested ?? {};
      const errors = Object.fromEntries(Object.entries(nested).map(([key, list]) => [key, list?.[0] ?? ""]));
      return fail(400, { errors, values });
    }

    const lead = parsed.output;
    const stored = new URLSearchParams(cookies.get("lead_source") ?? "");
    const qualified =
      lead.company_size !== "1_10" &&
      (lead.budget === "10k_50k" || lead.budget === "50k_plus") &&
      (lead.timeline === "this_quarter" || lead.timeline === "next_quarter");

    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.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) {
      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 fail(response.status >= 400 ? response.status : 502, { errors, values });
    }

    if (!qualified || lead._gotcha) redirect(303, "/contact-sales/thanks");
    redirect(303, `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}`, { external: true });
  },
} satisfies Actions;
  • picklist dari Valibot menolak select kosong dengan pesan Anda, dan flatten memberi satu daftar pesan per field.
  • fail() juga mengembalikan nilainya, karena halaman tidak punya salinan lain setelah kiriman native. SvelteKit 3 mengirim status fail sebagai status HTTP.
  • Header Origin dari browser diteruskan, karena fetch di sisi server tidak mengirimnya dan Ikiform mendeteksi uji localhost gratis dari header itu.
  • redirect() melempar exception, jadi tidak ada yang berjalan setelahnya. Sejak SvelteKit 2 Anda memanggilnya tanpa throw, dan sejak SvelteKit 3 target di origin lain butuh { external: true }, atau action gagal alih-alih me-redirect.
  • Halaman dengan action tidak bisa di-prerender. prerender = false membuatnya eksplisit jika layout induk mengaktifkan prerendering.

Halamannya

src/routes/contact-sales/+page.svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  let pending = $state(false);
  const errors = $derived<Record<string, string | undefined>>(form?.errors ?? {});
  const values = $derived<Record<string, string | undefined>>(form?.values ?? {});

  const selects = [
    { id: "company_size", label: "Company size", options: [["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"]] },
    { id: "budget", label: "Budget", options: [["under_10k", "Under $10k"], ["10k_50k", "$10k to $50k"], ["50k_plus", "More than $50k"], ["unsure", "Not sure yet"]] },
    { id: "timeline", label: "When do you want to start?", options: [["this_quarter", "This quarter"], ["next_quarter", "Next quarter"], ["later", "Later this year"], ["researching", "Researching for now"]] },
  ];
</script>

<form
  method="POST"
  use:enhance={() => {
    pending = true;
    return async ({ update }) => {
      await update({ reset: false });
      pending = false;
    };
  }}
>
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required value={values.name ?? ""} />
  {#if errors.name}<p role="alert">{errors.name}</p>{/if}

  <label for="email">Work email</label>
  <input id="email" name="email" type="email" autocomplete="email" required value={values.email ?? ""} />
  {#if errors.email}<p role="alert">{errors.email}</p>{/if}

  <label for="company">Company</label>
  <input id="company" name="company" autocomplete="organization" required value={values.company ?? ""} />
  {#if errors.company}<p role="alert">{errors.company}</p>{/if}

  {#each selects as select (select.id)}
    <label for={select.id}>{select.label}</label>
    <select id={select.id} name={select.id} required value={values[select.id] ?? ""}>
      <option value="" disabled>Choose one</option>
      {#each select.options as [value, text] (value)}
        <option {value}>{text}</option>
      {/each}
    </select>
    {#if errors[select.id]}<p role="alert">{errors[select.id]}</p>{/if}
  {/each}

  <label for="message">Anything we should know?</label>
  <textarea id="message" name="message" rows="4">{values.message ?? ""}</textarea>

  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  {#if errors.form}<p role="alert">{errors.form}</p>{/if}
  <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>

use:enhance menjalankan perilaku default lewat update(), tetapi reset: false menjaga input setelah kegagalan. Tanpa JavaScript, data fail dari action kembali di prop form dan atribut value mengisi ulang setiap field.

Honeypot dan pemeriksaan manusia

Honeypot ada di schema, jadi sampai ke Ikiform dan yang terisi disimpan sebagai spam; action mengarahkannya ke halaman terima kasih. Pemeriksaan CSRF bawaan SvelteKit sudah menolak form post dari origin lain. Pemeriksaan manusia Ikiform dilewati dengan secret key, jadi verifikasi token Turnstile di action jika Anda membutuhkannya, seperti di Turnstile di formulir.

Catatan deploy

HostYang perlu diketahui
Vercel@sveltejs/adapter-vercel, atau adapter-auto. Tambahkan IKIFORM_SECRET_KEY di environment variable proyek.
Netlify@sveltejs/adapter-netlify. Atur key di pengaturan situs.
Cloudflare@sveltejs/adapter-cloudflare. Simpan key dengan wrangler secret put. Key hanya ada saat runtime, yang ditangani $app/env/private, karena variabel yang dideklarasikan dibaca saat aplikasi dimulai.

Pastikan berfungsi

Kiriman dari vite 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 vite dev, buka /?utm_source=test&utm_medium=email, dan periksa cookie lead_source di dev tools.
  2. Di /contact-sales, pilih ukuran perusahaan, biarkan Budget kosong, lalu kirim. Error anggaran tampil dan jawaban lain Anda tetap ada.
  3. Kirim sebagai lead yang lolos kualifikasi: 201 sampai 1.000 orang, $10rb sampai $50rb, kuartal ini. Halaman memuat Cal.com dengan nama dan email terisi.
  4. Kirim sebagai Later this year. Anda mendarat di /contact-sales/thanks.
  5. Buka Submissions di dashboard dan periksa utm_medium, 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
403 Cross-site POST form submissions are forbiddenPemeriksaan CSRF SvelteKit melihat origin yang tidak cocok. Di balik proxy yang tidak meneruskan host, atur paths.origin di opsi sveltekit() di vite.config.ts; SvelteKit 3 menghapus variabel ORIGIN milik adapter-node. Untuk situs lain milik Anda, tambahkan ke csrf.trustedOrigins.
Redirect ke Cal.com gagal dengan error tentang URL eksternalSvelteKit 3 menolak redirect eksternal secara default. Teruskan { external: true }, atau allowlist seperti { external: ["https://cal.com"] }, sebagai argumen ketiga.
Halaman tidak berpindah ke Cal.com dan konsol menampilkan errorCallback use:enhance kustom memanggil goto() pada redirect. goto menolak URL eksternal; panggil update() atau applyAction() sebagai gantinya.
Field menjadi kosong setelah errorupdate() dipanggil tanpa reset: false, atau input tidak membaca form.values.
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.