# Buat formulir pendaftaran newsletter di SvelteKit

> Formulir newsletter SvelteKit 3 di layout: form action dengan persetujuan dan batas per IP, use:enhance yang tetap di halaman, dan route webhook Kit.

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

Taruh form action di halaman `/newsletter` dan kirim ke sana dari formulir di root layout. `use:enhance` dengan `update({ navigate: false })` menerapkan hasilnya ke halaman yang sedang dibaca, jadi footer menampilkannya tanpa meninggalkan artikel, dan tanpa JavaScript kiriman yang sama mendarat di `/newsletter`. Action memeriksa persetujuan, membatasi setiap IP dengan `getClientAddress()`, meneruskan pendaftaran ke backend formulir Anda, dan route `+server.ts` meneruskan pendaftaran terverifikasi ke Kit.

[Tab SvelteKit di panduan utama](https://ikiform.com/id/guides/newsletter-signup.md#form) mengirim dari footer ke action `/newsletter` dan menangani hasilnya secara manual. Halaman ini membangunnya dengan cara SvelteKit 3: environment variable eksplisit, `update({ navigate: false })` agar framework yang menerapkan hasilnya, batas per IP, dan route webhook untuk Kit. Penyiapan produk ada di [panduan dokumentasi SvelteKit](https://ikiform.com/id/docs/guides/sveltekit.md).

## Apa yang diubah formulir di layout pada SvelteKit

Form action hanya ada di `+page.server.ts`, tidak pernah di layout, jadi formulir footer mengirim ke action halaman lain. SvelteKit 3 membuat formulir yang di-enhance berperilaku seperti formulir native di sini: formulir yang action-nya di halaman lain akan bernavigasi ke halaman itu. Widget newsletter adalah kasus yang disebut dokumentasi untuk keluar dari perilaku itu, dengan `update({ navigate: false })`. Hasilnya kemudian diterapkan ke halaman saat ini sebagai `page.form`, yang dibaca footer.

Diagram: Bagaimana pendaftaran dari footer SvelteKit kembali ke halaman yang sedang dibaca

1. Pembaca mengirim formulir footer. use:enhance mengirimnya ke action subscribe di /newsletter di latar belakang.
2. Action memeriksa kotak persetujuan, lalu menghitung percobaan terbaru dari IP pembaca.
3. Jika melewati batas, action mengembalikan fail(429) dan tidak ada yang dikirim.
4. Jika tidak, action mengirim pendaftaran ke Ikiform dengan secret key dan Origin browser.
5. update dengan navigate false menerapkan hasilnya ke halaman saat ini sebagai page.form, dan footer menampilkannya. Tanpa JavaScript, browser pergi ke /newsletter, yang merender komponen yang sama.

Hanya formulir yang source-nya cocok dengan hasil yang bereaksi, jadi formulir footer dan formulir halaman tidak berubah bersamaan.

## Setup proyek

`Struktur proyek`:

```text
src/
  env.ts
  lib/
    NewsletterForm.svelte
    server/limit.ts
  routes/
    +layout.svelte
    newsletter/
      +page.server.ts
      +page.svelte
    api/ikiform/+server.ts
.env
```

SvelteKit 3 menghentikan modul `$env/*` dan menggantinya dengan variabel eksplisit. Deklarasikan di `src/env.ts`; variabel itu privat dan dibaca saat runtime secara default, dan `$app/env/private` menolak di-import ke kode browser.

`src/env.ts`:

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

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

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
KIT_API_KEY=your_kit_v4_api_key
KIT_FORM_ID=1234567
```

Buat key Ikiform di **API keys** dengan scope `submissions:write` dan tempel [definisi field dari pilar](https://ikiform.com/id/guides/newsletter-signup.md#fields) di tab **Fields** formulir.

## Bangun

### Limit per IP

Seseorang berlangganan sekali. Tiga percobaan dalam sepuluh menit memberi ruang untuk salah ketik dan menghentikan script. `getClientAddress()` mengembalikan IP yang ditentukan adapter Anda, jadi limiter tidak perlu tahu header mana yang dipakai host Anda.

`src/lib/server/limit.ts`:

```ts
const WINDOW_MS = 10 * 60_000;
const LIMIT = 3;
const recent = new Map<string, number[]>();

export function allow(address: string, now = Date.now()) {
  const hits = (recent.get(address) ?? []).filter((time) => now - time < WINDOW_MS);
  if (hits.length >= LIMIT) return false;
  if (recent.size > 10_000) recent.clear();
  recent.set(address, [...hits, now]);
  return true;
}
```

Penghitung disimpan di memori satu server. Di host serverless setiap instance menghitung sendiri, jadi anggap ini sebagai polisi tidur dan pindahkan ke penyimpanan bersama saat traffic bertambah. File di direktori `server` tidak bisa di-import oleh kode browser. SvelteKit 3 menjangkau `src/lib` lewat alias `#lib` yang dideklarasikan proyek baru di bawah `imports` di `package.json`; alias `$lib` lama sudah tidak ada.

### Action-nya

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

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import { allow } from "#lib/server/limit.js";
import type { Actions } from "./$types";

export const prerender = false;

const PLACEMENTS = ["footer", "post", "newsletter-page"];

export const actions = {
  subscribe: async ({ request, fetch, getClientAddress, url }) => {
    const form = await request.formData();
    const email = String(form.get("email") ?? "").trim();
    const picked = String(form.get("source") ?? "");
    const source = PLACEMENTS.includes(picked) ? picked : "footer";
    const refuse = (status: number, field: string, message: string) =>
      fail(status, { newsletter: { ok: false, source, field, message, email } });

    if (form.get("consent") !== "true") return refuse(422, "consent", "Tick the box so we know you want the emails.");
    if (!allow(getClientAddress())) return refuse(429, "form", "Too many tries from this connection. Wait a few minutes.");

    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,
        first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
        consent: true,
        source,
        _gotcha: String(form.get("_gotcha") ?? ""),
      }),
    }).catch(() => null);

    const result = await response?.json().catch(() => null);
    if (result?.ok) return { newsletter: { ok: true, source } };
    const emailError = result?.error?.fields?.email?.[0];
    if (emailError) return refuse(422, "email", emailError);
    return refuse(response?.status ?? 502, "form", result?.error?.message ?? "We couldn't reach the list. Try again.");
  },
} satisfies Actions;
```

- Hasilnya dibungkus dalam `newsletter`, jadi tidak pernah bertabrakan dengan hasil formulir lain di `page.form`.
- `fail()` mempertahankan email yang diketik. SvelteKit 3 juga mengirim status dari `fail()` untuk formulir yang di-enhance, jadi tab network menampilkan `422` atau `429`.
- Baris `Origin` meneruskan origin browser, karena Ikiform mengenali uji `localhost` gratis dari header itu dan `fetch` milik server tidak mengirimnya.
- `prerender = false` penting jika root layout Anda melakukan prerender semuanya: halaman dengan action tidak bisa di-prerender.

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

### Komponen formulir

`src/lib/NewsletterForm.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import { page } from "$app/state";

  type Outcome = { ok: boolean; source: string; field?: string; message?: string; email?: string };

  let { source = "footer" }: { source?: "footer" | "post" | "newsletter-page" } = $props();
  let pending = $state(false);
  const outcome = $derived.by(() => {
    const value = page.form?.newsletter as Outcome | undefined;
    return value?.source === source ? value : undefined;
  });
</script>

{#if outcome?.ok}
  <p role="status">Almost done. Open the email we sent you and confirm your address.</p>
{:else}
  <form
    method="POST"
    action="/newsletter?/subscribe"
    use:enhance={() => {
      pending = true;
      return async ({ update }) => {
        await update({ navigate: false, refreshAll: false, reset: false });
        pending = false;
      };
    }}
  >
    <label>
      Email
      <input name="email" type="email" required autocomplete="email" value={outcome?.email ?? ""} aria-invalid={outcome?.field === "email"} />
    </label>
    <label>
      First name (optional)
      <input name="first_name" maxlength="80" autocomplete="given-name" />
    </label>
    <label>
      <input name="consent" type="checkbox" value="true" required aria-invalid={outcome?.field === "consent"} />
      Email me the newsletter. I can unsubscribe at any time.
    </label>
    <input type="hidden" name="source" value={source} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if outcome?.message}
      <p role="alert">{outcome.message}</p>
    {/if}
    <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
  </form>
{/if}
```

- `navigate: false` membuat pembaca tetap di artikel. `refreshAll: false` melewati menjalankan ulang setiap function `load` setelah pendaftaran berhasil, yang tidak mengubah apa pun yang dimuatnya.
- `reset: false` mempertahankan email yang diketik saat ada error. Saat berhasil, formulir toh diganti.
- Dengan JavaScript mati, kiriman pergi ke `/newsletter`, yang merender komponen yang sama ini, dan `page.form` membawa hasilnya ke sana.

### Halaman /newsletter

`src/routes/newsletter/+page.svelte`:

```svelte
<script lang="ts">
  import NewsletterForm from "#lib/NewsletterForm.svelte";
</script>

<svelte:head>
  <title>Newsletter</title>
</svelte:head>

<h1>The monthly newsletter</h1>
<p>What we shipped, what broke and what we learned. One email a month.</p>
<NewsletterForm source="newsletter-page" />
```

Render `<NewsletterForm />` di `+layout.svelte` untuk footer, dan berikan `source="post"` di akhir artikel.

## Teruskan subscriber ke Kit

Route `+server.ts` ini menerima webhook bertanda tangan dari Ikiform, membuat subscriber di Kit sebagai tidak aktif dan menambahkannya ke formulir Kit, yang email insentifnya menjadi konfirmasi dari Kit; uji sekali dengan alamat milik Anda. Route membaca body sebagai teks lebih dulu karena tanda tangan mencakup byte persisnya, dan mengembalikan objek `Response` biasa, karena SvelteKit 3 menghentikan helper `json()` dan `text()`. [Panduan Kit](https://ikiform.com/id/guides/newsletter-signup/kit.md) menjelaskan setiap jawaban Kit dan menambahkan tag.

`src/routes/api/ikiform/+server.ts`:

```ts
import { IKIFORM_WEBHOOK_SECRET, KIT_API_KEY, KIT_FORM_ID } from "$app/env/private";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import type { RequestHandler } from "./$types";

export const POST: RequestHandler = async ({ request, fetch }) => {
  let event;
  try {
    event = await verifyWebhook({ payload: await request.text(), headers: request.headers, secret: IKIFORM_WEBHOOK_SECRET });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
  if (event.type !== "submission.created") return new Response(null, { status: 204 });

  const { email, first_name, consent } = event.data.fields;
  if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });

  const kit = (path: string, body: Record<string, unknown>) =>
    fetch(`https://api.kit.com/v4${path}`, {
      method: "POST",
      headers: { "X-Kit-Api-Key": KIT_API_KEY, "Content-Type": "application/json", Accept: "application/json" },
      body: JSON.stringify(body),
    });

  const created = await kit("/subscribers", {
    email_address: email,
    first_name: typeof first_name === "string" ? first_name : null,
    state: "inactive",
  });
  const added = created.ok ? await kit(`/forms/${KIT_FORM_ID}/subscribers`, { email_address: email }) : created;

  if (added.status === 429) return new Response("Kit rate limit", { status: 503, headers: { "retry-after": "60" } });
  if (!added.ok) return new Response(`Kit answered ${added.status}`, { status: 502 });
  return new Response(null, { status: 204 });
};
```

Perlindungan CSRF SvelteKit selalu aktif di versi 3, tetapi hanya menolak kiriman formulir lintas origin dan post lintas origin tanpa `Content-Type`. Ikiform mengirim `application/json`, jadi webhook lolos. Tambahkan URL di **Webhooks** dan salin secret `whsec_`.

## Honeypot dan pemeriksaan manusia

Input `_gotcha` diteruskan dan Ikiform menyimpan yang terisi sebagai spam, dengan jawaban sukses dan tanpa webhook, jadi Kit tidak pernah melihatnya. Secret key melewati pemeriksaan Turnstile Ikiform; batas IP di atas mencakup loop yang akan dijalankan script. Untuk bot yang memakai banyak IP, verifikasi token Turnstile di action, seperti yang ditunjukkan [panduan Turnstile](https://ikiform.com/id/guides/spam-protection/cloudflare-turnstile.md).

## Deploy

| Host | Yang harus dilakukan |
| --- | --- |
| Vercel | `adapter-vercel` menjalankan action sebagai function. SvelteKit 3 menghapus edge runtime di sana, jadi gunakan default. Atur variabel di **Environment Variables**. |
| Netlify | `adapter-netlify` mengikuti Frameworks API Netlify, dan publish directory kini menjadi opsi adapter. Atur variabel di pengaturan environment situs. |
| Cloudflare | `adapter-cloudflare` melakukan build untuk Workers dan butuh Wrangler 4.118 atau lebih baru, sebagai peer dependency-nya. Dokumentasinya menyarankan `$app/env` untuk variabel. Gunakan domain kustom untuk webhook; Ikiform menolak `workers.dev` dan `pages.dev`. |

## Uji gratis di localhost

Workspace tanpa paket menerima 50 kiriman uji per bulan dari `localhost`, dan `Origin` yang diteruskan membuat setiap pendaftaran dari dev server dihitung sebagai salah satunya. Limiter juga menghitung Anda, jadi restart dev server jika Anda mencapainya saat menguji. Ikiform tidak akan mengirim webhook ke `localhost`; ekspos dev server lewat tunnel dengan URL HTTPS untuk menguji route.

## Pastikan berfungsi

1. Jalankan `vite dev`, buka halaman mana pun, dan berlangganan dari footer. Barisnya muncul di footer dan URL tidak berubah.
2. Matikan JavaScript dan berlangganan lagi. Anda mendarat di `/newsletter` dengan baris yang sama.
3. Kirim empat kali dalam sepuluh menit. Yang keempat menampilkan pesan batas, dan tab network menampilkan `429`.
4. Buka **Submissions** di dashboard. Kiriman baru ada di sana dengan setiap field yang Anda kirim.
5. Jika Anda menambahkan webhook, buka endpoint-nya di **Webhooks**. Pengiriman menampilkan `succeeded` dengan jawaban dari server Anda.
6. Buka Kit. Subscriber ada di sana sebagai inactive, dan email konfirmasi Kit ada di inbox Anda.

## Hal yang sering menjebak

| Masalah | Yang harus dilakukan |
| --- | --- |
| Mengirim formulir footer melompat ke `/newsletter` meski ada JavaScript | Callback `use:enhance` memanggil `update()` tanpa `navigate: false`. Di SvelteKit 3, action lintas halaman bernavigasi secara default. |
| Build gagal karena `/newsletter` tidak bisa di-prerender | Sebuah layout mengatur `prerender = true`. Halaman dengan action butuh server; atur `prerender = false` di file server halaman itu. |
| Formulir footer dan formulir halaman sama-sama menampilkan hasil | Keduanya membaca `page.form` yang sama. Bandingkan `source`-nya dengan milik komponen, seperti di contoh. |
| `402 plan_required` pada uji lokal | Kiriman tidak punya origin `localhost`. Periksa baris `Origin`, dan buka situs di `localhost`. |
| Kiriman ada di dashboard tetapi tidak ada email atau webhook yang datang | Kiriman itu disimpan sebagai spam. Spam tidak mengirim apa pun. Buka di filter **Spam** untuk melihat alasannya, dan tandai sebagai bukan spam untuk mengirim email dan webhook-nya. |

## Pertanyaan

### Bagaimana cara menaruh form action SvelteKit di layout?

Anda tidak bisa mendefinisikannya di sana; action berada di file +page.server. Taruh action di halaman seperti /newsletter dan kirim ke sana dari layout dengan action="/newsletter?/subscribe". Gunakan use:enhance dengan update({ navigate: false }) agar tetap di halaman saat ini.

### Bisakah situs SvelteKit yang di-prerender punya formulir newsletter?

Bisa. Lakukan prerender artikel dan serahkan hanya halaman dengan action, serta route webhook, ke server. Situs tanpa server sama sekali bisa mengirim dari browser langsung ke Ikiform.

### Mengapa subscriber sampai ke Kit lewat webhook?

Karena hanya webhook yang tahu siapa yang asli. Form action mendapat jawaban sukses yang sama untuk bot maupun pembaca, sedangkan webhook hanya terpicu untuk pendaftaran yang lolos pemeriksaan spam Ikiform, jadi Kit tidak pernah melihat bot.

### 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 pendaftaran newsletter](https://ikiform.com/id/guides/newsletter-signup.md): Buat formulir pendaftaran newsletter dengan markup Anda sendiri, catat persetujuan, blokir pendaftaran bot, dan teruskan setiap subscriber ke Kit, MailerLite, beehiiv, atau alat apa pun.
- [Kit (ConvertKit)](https://ikiform.com/id/guides/newsletter-signup/kit.md): Hubungkan formulir newsletter Anda sendiri ke Kit dengan relay yang membuat subscriber, menambahkannya ke formulir Kit untuk incentive email, dan memberi tag penempatannya.
- [Double opt-in](https://ikiform.com/id/guides/newsletter-signup/double-opt-in.md): Ikiform tidak mengirim email konfirmasi, jadi double opt-in berjalan di alat email Anda: relay menambahkan setiap pendaftaran sebagai belum terkonfirmasi dan alat itu mengirim tautannya.
- [Formulir newsletter Nuxt](https://ikiform.com/id/guides/newsletter-signup/nuxt.md): Formulir newsletter Nuxt yang bekerja dengan atau tanpa JavaScript: server route dengan pemeriksaan Turnstile mengirim ke Ikiform, dan route Nitro mengisi beehiiv.
- [Situs statis](https://ikiform.com/id/guides/newsletter-signup/static-site.md): Tambahkan formulir pendaftaran newsletter ke situs statis Hugo, Jekyll, Eleventy, atau Astro, jauhkan API key alat email dari halaman, dan lakukan konfirmasi di alat itu.
- [MailerLite](https://ikiform.com/id/guides/newsletter-signup/mailerlite.md): Hubungkan formulir pendaftaran Anda sendiri ke MailerLite: relay menambahkan pembaca baru sebagai belum terkonfirmasi untuk double opt-in dan tidak pernah meminta subscriber mengonfirmasi dua kali.
