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.
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:
| Item | Landing page yang di-prerender | Halaman yang dirender on demand |
|---|---|---|
| Disajikan dari | CDN, sebagai file | Server atau function adapter Anda |
<form action={actions.x}> | Tidak diizinkan. Astro memerlukan halaman on demand | Berfungsi tanpa JavaScript |
?ref= di URL | Tidak terlihat oleh server. Dibangun sekali, tanpa query | Bisa dibaca dari Astro.url |
Astro.clientAddress | Tidak tersedia | Tersedia |
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.
- Landing page adalah HTML biasa dari CDN. Saat skripnya dimuat, skrip menyalin ?ref= atau ?utm_source= ke input tersembunyi ref.
- Dengan JavaScript, skrip menghentikan kiriman biasa dan memanggil actions.joinWaitlist dengan data formulir. Action berjalan di server dan meneruskan pendaftaran ke Ikiform.
- Skrip mengganti formulir dengan kalimat terima kasih, atau menampilkan error di bawah input.
- Tanpa JavaScript, browser mengirim formulir ke /join, yang dirender on demand.
- /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.
Siapkan proyek
Penyiapan produk, seperti membuat formulir dan secret key, ada di panduan Astro di dokumentasi. File untuk halaman ini:
src/
actions/index.ts
components/WaitlistForm.astro
pages/index.astro
pages/join.astro
pages/thanks.astro
astro.config.mjs
.envAdapter 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.
npx astro add nodeimport { 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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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:
{
"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
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-parseFormData. Input kosong datang sebagainull, jadirefdan honeypot memakainullish(), danrefyang tidak ada menjadidirect.refharus berupa huruf kecil, angka,-, atau_, hingga 40 karakter. Selain itu disimpan sebagaidirect, jadi tautan yang direkayasa tidak bisa mengisi ekspor Anda dengan sampah. Batasnya sesuai denganmax_lengthdi definisi.z.email()adalah skema email tingkat atas Zod 4, diekspor dariastro/zod. Alamat yang salah gagal di sini dan tidak pernah sampai ke Ikiform.- Browser mengirim header
Originbaik pada panggilan skrip maupun kiriman biasa. Ikiform menghitung kiriman uji gratis berdasarkan header itu, danfetchmilik 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
ActionErrordengan kode yang sesuai dengan masalahnya. Skrip dan/joinsama-sama membacaerror.message.
Komponen formulir
---
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>actionformulir 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=newsletterditandainewsletter. - 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
---
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:
---
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
| Host | Adapter | Secret-nya |
|---|---|---|
| Vercel | npx astro add vercel | Tambahkan IKIFORM_SECRET_KEY di Environment Variables proyek |
| Netlify | npx astro add netlify | Tambahkan di Site configuration โบ Environment variables |
| Cloudflare | npx astro add cloudflare | Tambahkan 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
- Jalankan
astro devdan bukahttp://localhost:4321/?ref=test. Mendaftar. Formulir berubah menjadi kalimat terima kasih tanpa reload. - Buka Submissions di dashboard. Pendaftaran ada di sana dengan
refbernilaitest. - Matikan JavaScript di browser, reload, dan mendaftar lagi. Anda mendarat di
/thanks, dan pendaftaran baru memilikirefbernilaidirect. - Kirim
not-an-emaildengan JavaScript mati./joinmenampilkan formulir lagi dengan error email. - Kirim dengan honeypot terisi,
_gotcha=bot. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook. - Kiriman dari
astro devmembawa originlocalhost, jadi dihitung sebagai kiriman uji. Ikiform tidak punya paket gratis. Pengujian gratis: workspace tanpa paket menerima 50 kiriman uji per bulan darilocalhost. 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
| Masalah | Yang harus dilakukan |
|---|---|
403 dari situs Anda sendiri pada kiriman biasa | security.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-prerender | Sebuah 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 direct | Tautan 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_field | Definisi field formulir 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
Bisa. Biarkan landing page di-prerender, panggil action dari skrip klien, dan arahkan action biasa formulir ke satu halaman yang dirender on demand. Hanya action dan halaman itu yang memerlukan server.
Ya. Actions berjalan di server saat dipanggil, jadi proyek memerlukan adapter seperti Node, Vercel, Netlify, atau Cloudflare, meskipun setiap halaman di-prerender.
Bisa saja, dan cara itu mempertahankan rate limit per pengunjung dan pemeriksaan manusia Ikiform. Action layak dipakai saat Anda ingin memvalidasi dan membersihkan tag sumber terlebih dahulu, atau menjauhkan endpoint dari HTML halaman Anda.
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.