Daftar tunggu untuk SaaS baru

Daftar tunggu untuk SaaS baru7 menit baca

Membuat daftar tunggu di Astro

Daftar tunggu Astro di landing page statis: Action meneruskan setiap pendaftaran, skrip memanggilnya, dan satu halaman on-demand menangkap kiriman tanpa JavaScript.

Preet SutharPendiri, Ikiform

Biarkan landing page tetap di-prerender dan letakkan pekerjaan server di Astro Action yang memvalidasi email dan meneruskannya ke backend formulir Anda dengan secret key. Skrip singkat memanggil action itu sehingga pengunjung tetap di halaman, dan action biasa formulir mengarah ke satu halaman on-demand yang menjalankan action yang sama saat JavaScript belum dimuat.

Panduan utama punya tab Astro yang singkat: satu action, satu formulir, dan satu halaman yang dirender on demand. Itu berfungsi, tetapi mengorbankan alasan utama orang memilih Astro. Landing page adalah halaman yang paling ingin Anda sajikan sebagai HTML statis dari CDN, sementara form action Astro memaksanya dirender di setiap request. Halaman ini mempertahankan landing page tetap statis dan hanya memindahkan pendaftaran ke server.

Apa yang berbeda di Astro

Astro melakukan prerender setiap halaman secara default. Hal ini berdampak dua hal pada daftar tunggu yang tidak dialami framework lain:

ItemLanding page yang di-prerenderHalaman yang dirender on demand
Disajikan dariCDN, sebagai fileServer atau function adapter Anda
<form action={actions.x}>Tidak diizinkan. Astro memerlukan halaman on demandBerfungsi tanpa JavaScript
?ref= di URLTidak terlihat oleh server. Dibangun sekali, tanpa queryBisa dibaca dari Astro.url
Astro.clientAddressTidak tersediaTersedia

Jadi landing page tidak bisa memakai pola form action, dan tidak bisa membaca ?ref= saat build. Solusinya adalah membagi tugas: halaman statis membawa formulir dan skrip, action mengerjakan pekerjaan server, dan satu halaman kecil, /join, dirender on demand untuk menangkap kiriman yang datang tanpa JavaScript.

Dua jalur menuju satu Astro Action dari landing page statisDengan JavaScriptTanpa JavaScripthalamanstatisskripmengisi refdari URLactions.joinWaitlist()berjalan diserverIkiformsecret key,menyimpannyakalimatterimakasihhalamansamahalamanstatisformulirdikirim ke/join/joinon demand,Astro.callActionIkiformaction yangsama/thanksredirect303FormDataJSONhasilPOSTJSON303Dua jalur menuju satu Astro Action dari landing page statisDengan JavaScriptTanpa JavaScripthalaman statisskrip mengisi ref dari URLactions.joinWaitlist()berjalan di serverIkiformsecret key, menyimpannyakalimat terima kasihhalaman samahalaman statisformulir dikirim ke /join/joinon demand, Astro.callActionIkiformaction yang sama/thanksredirect 303FormDataJSONhasilPOSTJSON303
  1. Landing page adalah HTML biasa dari CDN. Saat skripnya dimuat, skrip menyalin ?ref= atau ?utm_source= ke input tersembunyi ref.
  2. Dengan JavaScript, skrip menghentikan kiriman biasa dan memanggil actions.joinWaitlist dengan data formulir. Action berjalan di server dan meneruskan pendaftaran ke Ikiform.
  3. Skrip mengganti formulir dengan kalimat terima kasih, atau menampilkan error di bawah input.
  4. Tanpa JavaScript, browser mengirim formulir ke /join, yang dirender on demand.
  5. /join memanggil action yang sama dengan Astro.callAction. Jika sukses, ia me-redirect ke /thanks dengan 303, jadi refresh tidak mengirim ulang. Jika error, ia menampilkan formulir beserta pesannya.
Satu action, dua jalan masuk. Hanya /join dan action itu sendiri yang memerlukan server.

Siapkan proyek

Penyiapan produk, seperti membuat formulir dan secret key, ada di panduan Astro di dokumentasi. File untuk halaman ini:

Proyek
src/
  actions/index.ts
  components/WaitlistForm.astro
  pages/index.astro
  pages/join.astro
  pages/thanks.astro
astro.config.mjs
.env

Adapter dan secret bertipe

Actions berjalan di server saat request, jadi proyek memerlukan adapter meskipun sebagian besar halaman tetap statis. astro add memasang adapter dan mengedit konfigurasi. Ganti node dengan vercel, netlify, atau cloudflare sesuai host Anda.

Terminal
npx astro add node
astro.config.mjs
import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
    },
  },
});

envField dengan access: "secret" membuat IKIFORM_SECRET_KEY hanya bisa diimpor dari astro:env/server. Impor di skrip klien dan build akan gagal, dan memang itu tujuannya. Buat key di API keys dengan scope submissions:write.

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

Definisi field

Action mengirim email dan ref. Jika Anda menambahkan definisi field di tab Fields formulir, keduanya harus dicantumkan, atau Ikiform menolak kiriman dengan 422 dan unknown_field:

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

Action-nya

src/actions/index.ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";

const SOURCE = /^[a-z0-9_-]{1,40}$/;

export const server = {
  joinWaitlist: defineAction({
    accept: "form",
    input: z.object({
      email: z.email("Enter the email we should send your invite to."),
      ref: z
        .string()
        .nullish()
        .transform((value) => (value && SOURCE.test(value) ? value : "direct")),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const origin = context.request.headers.get("origin");
      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 ? { Origin: origin } : {}),
        },
        body: JSON.stringify({
          email: input.email,
          ref: input.ref,
          _gotcha: input._gotcha ?? "",
        }),
        signal: AbortSignal.timeout(8000),
      }).catch(() => null);

      if (!response) {
        throw new ActionError({ code: "GATEWAY_TIMEOUT", message: "We couldn't reach the list. Try again." });
      }
      if (response.status === 429) {
        throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Lots of people are joining. Try again in a minute." });
      }
      const result = await response.json().catch(() => null);
      if (result?.ok) return { joined: true };
      throw new ActionError({
        code: "BAD_REQUEST",
        message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      });
    },
  }),
};
  • accept: "form" mem-parse FormData. Input kosong datang sebagai null, jadi ref dan honeypot memakai nullish(), dan ref yang tidak ada menjadi direct.
  • ref harus berupa huruf kecil, angka, -, atau _, hingga 40 karakter. Selain itu disimpan sebagai direct, jadi tautan yang direkayasa tidak bisa mengisi ekspor Anda dengan sampah. Batasnya sesuai dengan max_length di definisi.
  • z.email() adalah skema email tingkat atas Zod 4, diekspor dari astro/zod. Alamat yang salah gagal di sini dan tidak pernah sampai ke Ikiform.
  • Browser mengirim header Origin baik pada panggilan skrip maupun kiriman biasa. Ikiform menghitung kiriman uji gratis berdasarkan header itu, dan fetch milik action tidak mengirimnya, jadi action meneruskannya.
  • Honeypot diteruskan tanpa diubah, jadi Ikiform tetap menandai bot yang mengisinya sebagai spam. Kiriman dengan secret key melewati pemeriksaan manusia Ikiform, jadi mengaktifkan Spam check tidak menambah apa pun di sini; action-lah penjaganya.
  • Setiap kegagalan melempar ActionError dengan kode yang sesuai dengan masalahnya. Skrip dan /join sama-sama membaca error.message.

Komponen formulir

src/components/WaitlistForm.astro
---
interface Props {
  error?: string;
}

const { error = "" } = Astro.props;
---

<form method="POST" action="/join" data-waitlist>
  <label>
    Email
    <input name="email" type="email" autocomplete="email" required placeholder="you@company.com" />
  </label>
  <input type="hidden" name="ref" value="direct" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <p role="alert">{error}</p>
  <button>Join the waitlist</button>
</form>

<script>
  import { actions, isInputError } from "astro:actions";

  const query = new URLSearchParams(location.search);
  const source = (query.get("ref") ?? query.get("utm_source") ?? "").toLowerCase().slice(0, 40);

  for (const form of document.querySelectorAll<HTMLFormElement>("form[data-waitlist]")) {
    const ref = form.elements.namedItem("ref") as HTMLInputElement;
    const alert = form.querySelector<HTMLElement>("[role=alert]")!;
    const button = form.querySelector("button")!;
    if (source) ref.value = source;

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      button.disabled = true;
      button.textContent = "Joining";
      alert.textContent = "";

      const { error } = await actions.joinWaitlist(new FormData(form));
      if (!error) {
        const done = document.createElement("p");
        done.setAttribute("role", "status");
        done.textContent = "You're on the list. We'll email you on launch day.";
        form.replaceWith(done);
        return;
      }
      alert.textContent = isInputError(error) ? (error.fields.email?.[0] ?? "Check the address.") : error.message;
      button.disabled = false;
      button.textContent = "Join the waitlist";
    });
  }
</script>
  • action formulir adalah /join, URL biasa. Tanpa JavaScript, browser mengirim ke sana, jadi formulir berfungsi sebelum skrip dimuat atau jika skrip gagal.
  • Skrip membaca ?ref= di browser, karena halaman yang di-prerender dibangun tanpa query string. Pengunjung dari /?ref=newsletter ditandai newsletter.
  • Astro membundel <script> komponen sekali per halaman, jadi loop-nya menghubungkan setiap formulir daftar tunggu di halaman itu. Letakkan satu di hero dan satu di footer, dan keduanya berfungsi.
  • Label membungkus input alih-alih merujuk ke id, jadi dua salinan di satu halaman tidak bentrok.

Landing page dan /join

src/pages/index.astro
---
import WaitlistForm from "../components/WaitlistForm.astro";
---

<main>
  <h1>Acme is almost here</h1>
  <p>Leave your email and we'll send you an invite the day it opens.</p>
  <WaitlistForm />
</main>

Tidak ada ekspor prerender, jadi halaman tetap statis. /join memilih keluar dan menangani kiriman biasa:

src/pages/join.astro
---
import { actions, isInputError } from "astro:actions";
import WaitlistForm from "../components/WaitlistForm.astro";

export const prerender = false;

let error = "";
if (Astro.request.method === "POST") {
  const result = await Astro.callAction(actions.joinWaitlist, await Astro.request.formData());
  if (!result.error) return Astro.redirect("/thanks", 303);
  error = isInputError(result.error) ? (result.error.fields.email?.[0] ?? "Check the address.") : result.error.message;
  Astro.response.status = 400;
}
---

<main>
  <h1>Join the Acme waitlist</h1>
  <WaitlistForm error={error} />
</main>

Astro.callAction menjalankan action dengan validasi yang sama, jadi hanya ada satu tempat untuk diubah. 303 mengarahkan browser ke /thanks dengan GET, yaitu halaman statis yang Anda tulis dengan kalimat terima kasih yang sama.

Catatan deploy

HostAdapterSecret-nya
Vercelnpx astro add vercelTambahkan IKIFORM_SECRET_KEY di Environment Variables proyek
Netlifynpx astro add netlifyTambahkan di Site configuration โ€บ Environment variables
Cloudflarenpx astro add cloudflareTambahkan sebagai secret di pengaturan Worker, atau dengan wrangler secret put IKIFORM_SECRET_KEY

Di setiap host, landing page dan /thanks dikirim sebagai file statis, sedangkan action dan /join menjadi satu server function. Hari yang sepi hampir tidak memakan biaya, dan lonjakan saat peluncuran sebagian besar ditangani CDN.

Pastikan berfungsi

  1. Jalankan astro dev dan buka http://localhost:4321/?ref=test. Mendaftar. Formulir berubah menjadi kalimat terima kasih tanpa reload.
  2. Buka Submissions di dashboard. Pendaftaran ada di sana dengan ref bernilai test.
  3. Matikan JavaScript di browser, reload, dan mendaftar lagi. Anda mendarat di /thanks, dan pendaftaran baru memiliki ref bernilai direct.
  4. Kirim not-an-email dengan JavaScript mati. /join menampilkan formulir lagi dengan error email.
  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 astro 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
403 dari situs Anda sendiri pada kiriman biasasecurity.checkOrigin Astro, yang aktif secara default, menolak kiriman formulir ke halaman on-demand dari origin lain. Kirim dari host yang sama tempat situs berjalan, seperti localhost:4321 saat dev, bukan lewat domain preview yang berbeda.
Error build tentang form action di halaman yang di-prerenderSebuah formulir memakai action={actions.joinWaitlist} di halaman statis. Biarkan action sebagai /join, seperti di atas, atau tambahkan export const prerender = false ke halaman itu.
ref selalu directTautan memakai nilai dengan huruf kapital atau spasi, atau skrip tidak dimuat. Skrip mengubah nilai menjadi huruf kecil; apa pun yang tersisa di luar a-z, 0-9, -, dan _ dibuang oleh action.
422 dengan unknown_fieldDefinisi field formulir 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.