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

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

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](https://ikiform.com/id/guides/lead-capture.md#form) 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.

Diagram: Apa yang dilakukan SvelteKit dengan setiap jenis hasil action

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](https://ikiform.com/id/docs/guides/sveltekit.md) membahas key dan formulirnya.

`file`:

```text
src/
  env.ts
  hooks.server.ts
  routes/
    contact-sales/
      +page.server.ts
      +page.svelte
      thanks/+page.svelte
vite.config.ts
.env
```

`src/env.ts`:

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

## First touch di hook handle

`src/hooks.server.ts`:

```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`:

```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`:

```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](https://ikiform.com/id/guides/spam-protection/cloudflare-turnstile.md).

## 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](https://ikiform.com/id/pricing).

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

| 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

### Bisakah form action SvelteKit me-redirect ke URL eksternal?

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.

### Bagaimana cara membaca secret key di SvelteKit 3?

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.

### Bagaimana cara mempertahankan nilai formulir setelah action SvelteKit gagal?

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.

### 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 Nuxt](https://ikiform.com/id/guides/lead-capture/nuxt.md): 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.
- [Formulir lead Astro](https://ikiform.com/id/guides/lead-capture/astro.md): Formulir lead Astro berbasis Actions: validasi Zod, UTM first-touch di situs statis, halaman kontak on demand, dan redirect ke penjadwal 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.
