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.
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.
- Hook handle melihat setiap GET. Jika belum ada cookie lead_source dan ada tag UTM atau referrer dari luar, hook mengaturnya.
- SvelteKit me-resolve request dan merender halaman seperti biasa.
- Saat submit, use:enhance mengirim formulir dengan fetch dan mengatur status pending.
- Action memvalidasi dengan Valibot, membaca cookie, dan meneruskan lead bersama Origin pengunjung.
- Redirect ke Cal.com tidak bisa menjadi navigasi klien, jadi SvelteKit memuatnya sebagai halaman penuh.
- Hasil fail memperbarui prop form; update dengan reset false menjaga input sesuai yang diketik.
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.
src/
env.ts
hooks.server.ts
routes/
contact-sales/
+page.server.ts
+page.svelte
thanks/+page.svelte
vite.config.ts
.envimport { 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 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.First touch di hook handle
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
pathcookie ke/secara default. Mengaturnya secara eksplisit membuat contoh tetap berjalan di SvelteKit 2, yang mewajibkanpath. - 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
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;picklistdari Valibot menolakselectkosong dengan pesan Anda, danflattenmemberi satu daftar pesan per field.fail()juga mengembalikan nilainya, karena halaman tidak punya salinan lain setelah kiriman native. SvelteKit 3 mengirim statusfailsebagai status HTTP.- Header
Origindari browser diteruskan, karena fetch di sisi server tidak mengirimnya dan Ikiform mendeteksi ujilocalhostgratis dari header itu. redirect()melempar exception, jadi tidak ada yang berjalan setelahnya. Sejak SvelteKit 2 Anda memanggilnya tanpathrow, 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 = falsemembuatnya eksplisit jika layout induk mengaktifkan prerendering.
Halamannya
<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
| Host | Yang 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.
- Jalankan
vite dev, buka/?utm_source=test&utm_medium=email, dan periksa cookielead_sourcedi dev tools. - Di
/contact-sales, pilih ukuran perusahaan, biarkan Budget kosong, lalu kirim. Error anggaran tampil dan jawaban lain Anda tetap ada. - 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.
- Kirim sebagai Later this year. Anda mendarat di
/contact-sales/thanks. - Buka Submissions di dashboard dan periksa
utm_medium,landing_page, 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 |
|---|---|
403 Cross-site POST form submissions are forbidden | Pemeriksaan 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 eksternal | SvelteKit 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 error | Callback use:enhance kustom memanggil goto() pada redirect. goto menolak URL eksternal; panggil update() atau applyAction() sebagai gantinya. |
| Field menjadi kosong setelah error | update() dipanggil tanpa reset: false, atau input tidak membaca form.values. |
422 validation_failed | Kiriman tidak cocok dengan definisi field formulir. error.fields menyebutkan setiap field dan alasannya. Samakan name input dengan ID field. |
Pertanyaan
Bisa. Panggil redirect(303, 'https://...', { external: true }) di action; SvelteKit 3 menolak target eksternal tanpa opsi itu. Tanpa JavaScript browser mengikutinya, dan dengan use:enhance SvelteKit kembali ke pemuatan halaman penuh karena URL itu bukan bagian dari aplikasi Anda.
Deklarasikan di src/env.ts dengan defineEnvVars dan import dari $app/env/private. Nilainya dibaca saat runtime kecuali Anda mengatur static: true, jadi secret khusus runtime seperti secret Cloudflare Workers tetap bekerja. Modul $env masih berfungsi tetapi sudah deprecated.
Kembalikan dalam fail(400, { errors, values }) dan ikat value setiap input ke form.values. Dengan use:enhance, panggil update({ reset: false }) agar formulir tidak dikosongkan.
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.