Formulir pendaftaran newsletter7 menit baca
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.
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 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.
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.
- Pembaca mengirim formulir footer. use:enhance mengirimnya ke action subscribe di /newsletter di latar belakang.
- Action memeriksa kotak persetujuan, lalu menghitung percobaan terbaru dari IP pembaca.
- Jika melewati batas, action mengembalikan fail(429) dan tidak ada yang dikirim.
- Jika tidak, action mengirim pendaftaran ke Ikiform dengan secret key dan Origin browser.
- 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.
Setup proyek
src/
env.ts
lib/
NewsletterForm.svelte
server/limit.ts
routes/
+layout.svelte
newsletter/
+page.server.ts
+page.svelte
api/ikiform/+server.ts
.envSvelteKit 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.
import { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
IKIFORM_SECRET_KEY: {},
IKIFORM_WEBHOOK_SECRET: {},
KIT_API_KEY: {},
KIT_FORM_ID: {},
});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=1234567Buat key Ikiform di API keys dengan scope submissions:write dan tempel definisi field dari pilar 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.
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
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 dipage.form. fail()mempertahankan email yang diketik. SvelteKit 3 juga mengirim status darifail()untuk formulir yang di-enhance, jadi tab network menampilkan422atau429.- Baris
Originmeneruskan origin browser, karena Ikiform mengenali ujilocalhostgratis dari header itu danfetchmilik server tidak mengirimnya. prerender = falsepenting jika root layout Anda melakukan prerender semuanya: halaman dengan action tidak bisa di-prerender.
IKIFORM_SECRET_KEY di environment variable khusus server.Komponen formulir
<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: falsemembuat pembaca tetap di artikel.refreshAll: falsemelewati menjalankan ulang setiap functionloadsetelah pendaftaran berhasil, yang tidak mengubah apa pun yang dimuatnya.reset: falsemempertahankan email yang diketik saat ada error. Saat berhasil, formulir toh diganti.- Dengan JavaScript mati, kiriman pergi ke
/newsletter, yang merender komponen yang sama ini, danpage.formmembawa hasilnya ke sana.
Halaman /newsletter
<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 menjelaskan setiap jawaban Kit dan menambahkan tag.
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.
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
- Jalankan
vite dev, buka halaman mana pun, dan berlangganan dari footer. Barisnya muncul di footer dan URL tidak berubah. - Matikan JavaScript dan berlangganan lagi. Anda mendarat di
/newsletterdengan baris yang sama. - Kirim empat kali dalam sepuluh menit. Yang keempat menampilkan pesan batas, dan tab network menampilkan
429. - Buka Submissions di dashboard. Kiriman baru ada di sana dengan setiap field yang Anda kirim.
- Jika Anda menambahkan webhook, buka endpoint-nya di Webhooks. Pengiriman menampilkan
succeededdengan jawaban dari server Anda. - 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
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.
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.
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.
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.