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

Terbit 2026-10-25 · https://ikiform.com/id/guides/waitlist/astro

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](https://ikiform.com/id/guides/waitlist.md#form) 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:

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

Diagram: Dua jalur menuju satu Astro Action dari landing page statis

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](https://ikiform.com/id/docs/guides/astro.md). File untuk halaman ini:

`Proyek`:

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

```bash
npx astro add node
```

`astro.config.mjs`:

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

```bash
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](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.

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

```json
{
  "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`:

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

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

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

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

| 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

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](https://ikiform.com/id/pricing).

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

### Bisakah halaman daftar tunggu Astro tetap statis?

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.

### Apakah Astro Actions memerlukan adapter?

Ya. Actions berjalan di server saat dipanggil, jadi proyek memerlukan adapter seperti Node, Vercel, Netlify, atau Cloudflare, meskipun setiap halaman di-prerender.

### Mengapa tidak mengirim formulir Astro langsung ke Ikiform?

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.

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

- [Daftar tunggu untuk SaaS baru](https://ikiform.com/id/guides/waitlist.md): Bangun daftar tunggu pra-peluncuran di landing page Anda sendiri: formulir, perlindungan spam, duplikat, email konfirmasi, dan ekspor rapi di hari peluncuran.
- [Daftar tunggu Next.js](https://ikiform.com/id/guides/waitlist/nextjs.md): Daftar tunggu Next.js App Router: server action yang meneruskan pendaftaran dengan secret key, useActionState, terima kasih optimistis, dan batas per pengunjung.
- [Daftar tunggu dengan HTML biasa](https://ikiform.com/id/guides/waitlist/html.md): Halaman daftar tunggu lengkap tanpa build dengan HTML biasa yang tetap berfungsi saat JavaScript mati, menampilkan ucapan terima kasih inline saat skrip dimuat, dan punya fallback bila perlu.
- [Daftar peluncuran Product Hunt](https://ikiform.com/id/guides/waitlist/product-hunt.md): Kumpulkan daftar peluncuran berminggu-minggu sebelum Product Hunt, tandai setiap pendaftaran per channel, dan kirim email hari-H dalam dua gelombang mengikuti reset waktu Pasifik.
- [Pendaftaran beta dengan penyaringan](https://ikiform.com/id/guides/waitlist/beta-signup.md): Formulir lamaran beta dengan pertanyaan penyaringan yang ditegakkan server, dan skrip yang menilai pelamar serta memilih setiap kohort dari daftar.
- [Daftar tunggu aplikasi mobile](https://ikiform.com/id/guides/waitlist/mobile-app.md): Daftar tunggu pra-peluncuran untuk aplikasi iOS atau Android yang menanyakan ponsel yang dipakai, mengirim penguji iPhone ke TestFlight, dan mengisi closed test Play.
