Daftar tunggu untuk SaaS baru

Daftar tunggu untuk SaaS baru6 menit baca

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.

Preet SutharPendiri, Ikiform

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

FiturFungsinya di sini
handle di hooks.server.tsBerjalan sebelum setiap request yang dirender di server, jadi bisa melihat ?ref= di halaman mana pun tempat pengunjung mendarat
event.cookiesMenyimpan sumber pertama selama 30 hari. Action membacanya kembali, tanpa input tersembunyi yang bisa diutak-atik
Form action dengan use:enhanceMengirim tanpa JavaScript dan ditingkatkan saat JavaScript dimuat, dengan satu potong kode server
AturannyaHalaman 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(...).

?ref= pertama pengunjung SvelteKit mengikuti mereka sampai pendaftaranHari pertamaPendaftaran/blog/launch?ref=newskunjungan pertamahook handlememasangwaitlist_ref=news/pricing, lalu /tanpa query stringaction ?/joinmembaca waitlist_refIkiformref = newswaitlist_joinedkunjungan berikutnya:sudah terdaftarrequestmenjelajahJSONcookie?ref= pertama pengunjung SvelteKit mengikuti mereka sampai pendaftaranHari pertamaPendaftaran/blog/launch?ref=newskunjungan pertamahook handlememasang waitlist_ref=news/pricing, lalu /tanpa query stringaction ?/joinmembaca waitlist_refIkiformref = newswaitlist_joinedkunjungan berikutnya: sudahterdaftarrequestmenjelajahJSONcookie
  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. Halaman ini memakai SvelteKit 3. File-filenya:

Proyek
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
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});
.env
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 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
{
  "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
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
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
<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.

HostAdapterCatatan
Vercel@sveltejs/adapter-vercelSvelteKit 3 menghapus runtime edge di sini, jadi action berjalan di Node functions
Netlify@sveltejs/adapter-netlifyAtur key di Environment variables
Cloudflare@sveltejs/adapter-cloudflareMemerlukan 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.

Jebakan

MasalahYang harus dilakukan
Build gagal dengan pesan bahwa halaman dengan action tidak bisa di-prerenderSebuah layout mengatur prerender = true untuk setiap route. Atur export const prerender = false di +page.server.ts halaman daftar tunggu.
ref selalu directTautan 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 upgradeSvelteKit 3 menandai modul $env sebagai deprecated. Deklarasikan key di src/env.ts dan impor dari $app/env/private.
422 dengan unknown_fieldDefinisi field tidak mencantumkan ref. Tambahkan seperti pada definisi di atas.
402 plan_requiredWorkspace 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

Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.