# Membuat daftar tunggu di SvelteKit

> Daftar tunggu SvelteKit 3: hook menyimpan ?ref= pertama di cookie, form action bernama meneruskan pendaftaran, dan pengunjung yang kembali melihat bahwa mereka sudah terdaftar.

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

Baca `?ref=` di `hooks.server.ts` pada request pertama pengunjung dan simpan di cookie, sehingga pendaftaran dikreditkan ke tautan yang membawa mereka meskipun mereka sudah menjelajah ke halaman lain. Form action bernama membaca cookie itu, meneruskan pendaftaran ke backend formulir Anda dengan secret key, dan memasang cookie kedua sehingga halaman menyapa pengunjung yang kembali alih-alih bertanya lagi.

[Panduan utama](https://ikiform.com/id/guides/waitlist.md#form) punya tab SvelteKit: default action dan `use:enhance`, dengan `ref` tetap bernilai `homepage`. Halaman ini menambahkan apa yang dibutuhkan peluncuran sungguhan. Pengunjung tiba di posting blog dari newsletter, membaca sebentar, lalu mendaftar dua halaman kemudian. Anda ingin pendaftaran itu dikreditkan ke newsletter, bukan ke halaman tempat mereka mengetik email.

## Apa yang berbeda di SvelteKit

Dua fitur SvelteKit sangat cocok untuk ini, dan satu aturan menentukan penyiapannya:

| Fitur | Fungsinya di sini |
| --- | --- |
| `handle` di `hooks.server.ts` | Berjalan sebelum setiap request yang dirender di server, jadi bisa melihat `?ref=` di halaman mana pun tempat pengunjung mendarat |
| `event.cookies` | Menyimpan sumber pertama selama 30 hari. Action membacanya kembali, tanpa input tersembunyi yang bisa diutak-atik |
| Form action dengan `use:enhance` | Mengirim tanpa JavaScript dan ditingkatkan saat JavaScript dimuat, dengan satu potong kode server |
| Aturannya | Halaman dengan action tidak bisa di-prerender, karena server harus menjawab `POST`-nya |

Input tersembunyi tidak bisa melakukan ini. Ia hanya tahu halaman tempat formulir berada, dan tag di URL hilang setelah klik pertama. Cookie yang dipasang di server menyimpannya di setiap halaman, dan pengunjung tidak pernah melihat atau mengeditnya.

Ini atribusi first-touch: tautan bertag pertama yang menang. Kunjungan berikutnya dari tautan lain tidak menimpanya. Jika Anda lebih suka mengkreditkan tautan terakhir, hapus pengecekan `!event.cookies.get(...)`.

Diagram: ?ref= pertama pengunjung SvelteKit mengikuti mereka sampai pendaftaran

1. Pengunjung mendarat di posting blog dari tautan dengan ?ref=news.
2. Hook handle melihat tag itu, belum menemukan cookie waitlist_ref, dan memasangnya selama 30 hari.
3. Mereka membaca halaman harga lalu kembali ke beranda. Kedua URL itu tidak punya tag; cookie masih menyimpannya.
4. Mereka mendaftar. Action join membaca waitlist_ref dan meneruskan pendaftaran ke Ikiform dengan ref bernilai news.
5. Jika sukses, action memasang waitlist_joined, sehingga beranda menampilkan kalimat terima kasih pada kunjungan berikutnya alih-alih formulir.

Sumber disimpan di cookie httpOnly, bukan di formulir, jadi bertahan saat navigasi dan tidak bisa diedit di halaman.

## Siapkan proyek

Buat formulir dan secret key seperti di [panduan SvelteKit di dokumentasi](https://ikiform.com/id/docs/guides/sveltekit.md). Halaman ini memakai SvelteKit 3. File-filenya:

`Proyek`:

```text
src/
  env.ts
  hooks.server.ts
  routes/
    +page.server.ts
    +page.svelte
    blog/[slug]/+page.svelte
vite.config.ts
.env
```

### Deklarasikan secret

SvelteKit 3 menandai modul `$env/...` sebagai deprecated dan menggantinya dengan variabel yang dideklarasikan. Deklarasikan key di `src/env.ts`; variabel bersifat privat kecuali Anda menandainya publik, dan `$app/env/private` tidak bisa diimpor ke kode browser.

`src/env.ts`:

```ts
import { defineEnvVars } from "@sveltejs/kit/env";

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

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

> Versi sisi server mengirim dengan secret key, jadi Ikiform melewati **Allowed domains** dan pemeriksaan manusianya. Setiap kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sekaligus. 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.

Action mengirim `email` dan `ref`. Jika formulir punya definisi field di tab **Fields**, cantumkan keduanya:

`Field`:

```json
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}
```

## Bangun

### Tangkap sumbernya

`src/hooks.server.ts`:

```ts
import type { Handle } from "@sveltejs/kit/hooks";

const SOURCE = /^[a-z0-9_-]{1,40}$/;
const THIRTY_DAYS = 60 * 60 * 24 * 30;

export const handle: Handle = async ({ event, resolve }) => {
  const query = event.url.searchParams;
  const tag = (query.get("ref") ?? query.get("utm_source") ?? "").toLowerCase();

  if (SOURCE.test(tag) && !event.cookies.get("waitlist_ref")) {
    event.cookies.set("waitlist_ref", tag, { maxAge: THIRTY_DAYS, httpOnly: true, sameSite: "lax" });
  }
  return resolve(event);
};
```

- Tag diubah menjadi huruf kecil dan harus cocok dengan pola yang sama yang diizinkan `max_length` Ikiform, jadi tautan yang direkayasa tidak bisa menyimpan sampah.
- `httpOnly` menjauhkan cookie dari skrip halaman. `sameSite: "lax"` tetap mengirimnya pada `POST` milik formulir itu sendiri.
- Di SvelteKit 3, `path` cookie default-nya `/`, jadi cookie menjangkau setiap route tanpa perlu diatur.

### Action bernama

`src/routes/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions, PageServerLoad } from "./$types";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const YEAR = 60 * 60 * 24 * 365;

export const load: PageServerLoad = ({ cookies }) => ({
  joined: cookies.get("waitlist_joined") === "yes",
});

export const actions = {
  join: async ({ request, cookies, fetch, url }) => {
    const form = await request.formData();
    const email = String(form.get("email") ?? "").trim();
    if (!EMAIL.test(email)) return fail(400, { email, message: "Enter the email we should send your invite to." });

    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({
        email,
        ref: cookies.get("waitlist_ref") ?? "direct",
        _gotcha: String(form.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    }).catch(() => null);

    if (!response) return fail(504, { email, message: "We couldn't reach the list. Try again." });
    const result = await response.json().catch(() => null);
    if (!result?.ok) {
      return fail(response.status === 429 ? 429 : 400, {
        email,
        message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      });
    }

    cookies.set("waitlist_joined", "yes", { maxAge: YEAR, httpOnly: true, sameSite: "lax" });
    return { joined: true };
  },
} satisfies Actions;
```

- Action bernama, `join`, menyisakan ruang untuk action lain di halaman ini nanti. SvelteKit tidak mengizinkan default action berdampingan dengan action bernama.
- Header `Origin` browser diteruskan karena Ikiform menghitung kiriman uji gratis dari `localhost` berdasarkan header itu, dan `fetch` milik action tidak mengirimnya.
- `fail()` mengembalikan email yang diketik dan sebuah pesan. Di SvelteKit 3, submit yang di-enhance memakai status yang Anda berikan ke `fail()`, jadi `page.status` bernilai `400` atau `429`.
- `load` membaca `waitlist_joined` agar pengunjung yang kembali melihat bahwa mereka sudah terdaftar. Ini hanya kesopanan untuk satu browser, bukan deduplikasi; Ikiform tetap menyimpan pendaftaran kedua dari perangkat lain.

### Halamannya

`src/routes/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { data, form }: PageProps = $props();
  let pending = $state(false);
</script>

<h1>Acme is almost here</h1>

{#if data.joined || form?.joined}
  <p role="status">You're on the list. We'll email you on launch day.</p>
{:else}
  <form
    method="POST"
    action="?/join"
    use:enhance={() => {
      pending = true;
      return async ({ update }) => {
        await update({ reset: false });
        pending = false;
      };
    }}
  >
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.email ?? ""} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if form?.message}
      <p role="alert">{form.message}</p>
    {/if}
    <button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
  </form>
{/if}
```

- `action="?/join"` menamai action-nya. Tanpa JavaScript, browser mengirim ke sana dan SvelteKit merender halaman dengan `form` terisi.
- `use:enhance` mengirim dengan `fetch` setelah halaman di-hydrate. `update({ reset: false })` mempertahankan email di input setelah error, jadi pengunjung cukup memperbaiki salah ketik tanpa mengetik ulang.
- `pending` menonaktifkan tombol sampai action menjawab, yang mencegah kiriman ganda di ponsel yang lambat.
- `data.joined` berasal dari `load` dan `form.joined` dari action. Memeriksa keduanya mencakup pengunjung yang kembali dan pendaftaran baru dengan satu kondisi.

Honeypot sampai ke Ikiform tanpa diubah, jadi bot yang mengisinya disimpan sebagai spam. Kiriman dengan secret key melewati pemeriksaan manusia Ikiform, jadi mengaktifkan **Spam check** tidak menambah apa pun di sini.

## Prerendering dan deploy

Beranda punya action, jadi harus dirender di server. Posting blog dan konten lain bisa di-prerender dengan `export const prerender = true`, tetapi `handle` tidak berjalan saat pengunjung membuka file yang di-prerender; ia hanya berjalan sekali saat build. Tautan bertag yang langsung menuju halaman yang di-prerender tidak memasang cookie. Arahkan tautan kampanye ke halaman yang dirender di server, seperti beranda.

| Host | Adapter | Catatan |
| --- | --- | --- |
| Vercel | `@sveltejs/adapter-vercel` | SvelteKit 3 menghapus runtime `edge` di sini, jadi action berjalan di Node functions |
| Netlify | `@sveltejs/adapter-netlify` | Atur key di **Environment variables** |
| Cloudflare | `@sveltejs/adapter-cloudflare` | Memerlukan `wrangler` 4.118 atau lebih baru, peer dependency adapter. Atur key dengan `wrangler secret put IKIFORM_SECRET_KEY` |

## Pastikan berfungsi

1. Jalankan `vite dev`, buka `http://localhost:5173/?ref=test`, lalu hapus query dan reload. Cookie `waitlist_ref` masih bernilai `test` di dev tools browser.
2. Mendaftar. Kalimat terima kasih menggantikan formulir tanpa reload, dan pendaftaran di **Submissions** memiliki `ref` bernilai `test`.
3. Reload halaman. Yang muncul kalimat terima kasih, bukan formulir.
4. Hapus cookie, matikan JavaScript, dan kirim `a@b`. Halaman kembali dengan error dan alamat masih ada di input.
5. Kirim dengan honeypot terisi, `_gotcha=bot`. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.
6. Kiriman dari `vite dev` membawa origin `localhost`, jadi dihitung sebagai kiriman uji. 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).

## Jebakan

| Masalah | Yang harus dilakukan |
| --- | --- |
| Build gagal dengan pesan bahwa halaman dengan action tidak bisa di-prerender | Sebuah layout mengatur `prerender = true` untuk setiap route. Atur `export const prerender = false` di `+page.server.ts` halaman daftar tunggu. |
| `ref` selalu `direct` | Tautan bertag mengarah ke halaman yang di-prerender, jadi `handle` tidak pernah melihatnya, atau kunjungan sebelumnya sudah memasang cookie. Periksa cookie di dev tools. |
| Peringatan `$env/static/private` setelah upgrade | SvelteKit 3 menandai modul `$env` sebagai deprecated. Deklarasikan key di `src/env.ts` dan impor dari `$app/env/private`. |
| `422` dengan `unknown_field` | Definisi field tidak mencantumkan `ref`. Tambahkan seperti pada definisi di atas. |
| `402 plan_required` | Workspace tidak punya paket dan kiriman berasal dari situs live. Kiriman ditahan, tidak hilang. Pilih paket atau beli paket kiriman di **Settings › Plan and usage**, dan kiriman yang ditahan akan masuk. |

## Pertanyaan

### Bagaimana cara melacak sumber UTM di formulir SvelteKit?

Baca query di hook handle pada request pertama, simpan tag di cookie httpOnly, dan baca cookie itu di form action. Sumbernya bertahan saat navigasi dan tidak pernah muncul di formulir.

### Bisakah halaman SvelteKit dengan form action di-prerender?

Tidak. Halaman dengan action memerlukan server untuk menjawab POST-nya, jadi SvelteKit menolak melakukan prerender. Prerender halaman lain dan biarkan halaman formulir dirender di server.

### Apakah cookie waitlist_joined mencegah pendaftaran ganda?

Hanya di browser itu. Ikiform tidak melakukan deduplikasi berdasarkan email, jadi hapus duplikat di tempat daftar itu disimpan atau saat Anda mengekspornya.

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

- [Daftar tunggu untuk SaaS baru](https://ikiform.com/id/guides/waitlist.md): Bangun daftar tunggu pra-peluncuran di landing page Anda sendiri: formulir, perlindungan spam, duplikat, email konfirmasi, dan ekspor rapi di hari peluncuran.
- [Daftar tunggu Next.js](https://ikiform.com/id/guides/waitlist/nextjs.md): Daftar tunggu Next.js App Router: server action yang meneruskan pendaftaran dengan secret key, useActionState, terima kasih optimistis, dan batas per pengunjung.
- [Daftar tunggu Astro](https://ikiform.com/id/guides/waitlist/astro.md): Daftar tunggu Astro di landing page statis: Action meneruskan setiap pendaftaran, skrip memanggilnya, dan satu halaman on-demand menangkap kiriman tanpa JavaScript.
- [Daftar peluncuran Product Hunt](https://ikiform.com/id/guides/waitlist/product-hunt.md): Kumpulkan daftar peluncuran berminggu-minggu sebelum Product Hunt, tandai setiap pendaftaran per channel, dan kirim email hari-H dalam dua gelombang mengikuti reset waktu Pasifik.
- [Pendaftaran beta dengan penyaringan](https://ikiform.com/id/guides/waitlist/beta-signup.md): Formulir lamaran beta dengan pertanyaan penyaringan yang ditegakkan server, dan skrip yang menilai pelamar serta memilih setiap kohort dari daftar.
- [Daftar tunggu aplikasi mobile](https://ikiform.com/id/guides/waitlist/mobile-app.md): Daftar tunggu pra-peluncuran untuk aplikasi iOS atau Android yang menanyakan ponsel yang dipakai, mengirim penguji iPhone ke TestFlight, dan mengisi closed test Play.
