# Buat formulir pendaftaran newsletter di Astro

> Formulir newsletter Astro yang tetap di halaman prerender: Astro Action memeriksa persetujuan, mengirim ke Ikiform, dan endpoint menambahkan pembaca ke MailerLite.

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

Biarkan halaman Astro Anda di-prerender dan taruh formulir pendaftaran di komponen footer. Tanpa JavaScript, formulir mengirim ke satu halaman `/newsletter` on demand yang menjalankan Astro Action; dengan JavaScript, script kecil memanggil action yang sama dan pembaca tidak pernah meninggalkan artikel. Action memvalidasi email dan persetujuan dengan `astro/zod`, mengirim ke backend formulir Anda dengan secret key, dan endpoint API meneruskan pendaftaran yang terverifikasi ke MailerLite.

[Tab Astro di panduan utama](https://ikiform.com/id/guides/newsletter-signup.md#form) memakai Action di halaman yang dirender on demand. Sebagian besar situs Astro adalah blog dan dokumentasi yang setiap halamannya di-prerender, dan formulir footer harus ada di semuanya. Halaman ini menjaga halaman-halaman itu tetap statis dan tetap menjalankan pendaftaran di server. Key dan allowed domains ada di [panduan dokumentasi Astro](https://ikiform.com/id/docs/guides/astro.md).

## Apa yang diubah halaman statis

Dokumentasi Astro menyatakan halaman harus dirender on demand untuk memanggil action lewat atribut `action` formulir. Artikel yang di-prerender tidak bisa menangani POST itu, karena tidak ada server di belakangnya. Ada dua jalan memutar, dan build ini memakai keduanya:

| Jalur | Kapan berjalan | Yang dilihat pembaca |
| --- | --- | --- |
| Script memanggil `actions.subscribe()` | JavaScript termuat, kasus yang biasa | Baris status di bawah formulir berubah. Mereka tetap di artikel |
| Formulir mengirim ke `/newsletter?_action=...` | JavaScript mati atau belum termuat | Halaman `/newsletter` on demand, dengan hasil atau error-nya |

Hanya `/newsletter` dan endpoint webhook yang berjalan on demand. Setiap artikel tetap file statis, dan adapter menambahkan server function untuk kedua route itu.

Diagram: Dua cara pendaftaran dari footer Astro mencapai action yang sama

1. Dengan JavaScript, script footer menghentikan kiriman normal dan memanggil action dengan data formulir.
2. Action memvalidasi input, mengirim ke Ikiform, dan mengembalikan data atau error ke script, yang memperbarui baris status.
3. Tanpa JavaScript, browser mengirim formulir ke halaman /newsletter on demand dengan action di query string.
4. Astro menjalankan handler action yang sama, dan halaman membaca hasilnya dengan getActionResult lalu menampilkannya.

Satu handler, dua pintu masuk. Artikel statis tidak pernah butuh server sendiri.

## Setup proyek

`Struktur proyek`:

```text
src/
  actions/index.ts
  components/SignupForm.astro
  layouts/Base.astro
  pages/
    newsletter.astro
    api/ikiform.ts
astro.config.mjs
.env
```

Instal adapter dengan `astro add vercel`, `astro add netlify`, atau `astro add cloudflare`. Deklarasikan secret dengan `astro:env` alih-alih membaca `import.meta.env`: schema menggagalkan build saat variabel tidak ada, dan import yang sama bekerja di setiap adapter, termasuk runtime Cloudflare.

`astro.config.mjs`:

```js
import { defineConfig, envField } from "astro/config";
import vercel from "@astrojs/vercel";

export default defineConfig({
  adapter: vercel(),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
      IKIFORM_WEBHOOK_SECRET: envField.string({ context: "server", access: "secret" }),
      MAILERLITE_API_KEY: envField.string({ context: "server", access: "secret" }),
      MAILERLITE_GROUP_ID: envField.string({ context: "server", access: "secret" }),
    },
  },
});
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789
```

Buat secret key di **API keys** dengan scope `submissions:write`, dan tempel [definisi field dari pilar](https://ikiform.com/id/guides/newsletter-signup.md#fields) di tab **Fields** formulir.

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

export const server = {
  subscribe: defineAction({
    accept: "form",
    input: z.object({
      email: z.email({ error: "Enter an email address we can send the newsletter to." }),
      first_name: z.string().trim().max(80).nullish(),
      consent: z.boolean().refine((ticked) => ticked, { error: "Tick the box so we know you want the emails." }),
      source: z.enum(["footer", "post", "newsletter-page"]).catch("footer"),
      _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,
          first_name: input.first_name || undefined,
          consent: true,
          source: input.source,
          _gotcha: input._gotcha ?? "",
        }),
      }).catch(() => null);

      const result = await response?.json().catch(() => null);
      if (result?.ok) return { subscribed: true };
      throw new ActionError({
        code: response?.status === 429 ? "TOO_MANY_REQUESTS" : "BAD_REQUEST",
        message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "We couldn't reach the list. Try again.",
      });
    },
  }),
};
```

- `accept: "form"` membuat Astro mem-parsing `FormData`. Checkbox melewati koersi boolean, jadi dicentang bernilai `true` dan tidak dicentang bernilai `false`, yang diubah `refine` menjadi pesan persetujuan.
- Input teks kosong tiba sebagai `null`, jadi field opsional memakai `nullish()`, bukan `optional()`.
- `source` kembali ke `footer` untuk nilai apa pun di luar daftar, jadi kiriman rekayasa tidak bisa mengarang penempatan.
- Baris `Origin` meneruskan origin browser, karena Ikiform mengenali uji `localhost` gratis dari header itu dan `fetch` milik server tidak mengirimnya.
- Error Ikiform menjadi `ActionError`. `429` tetap bermakna sebagai `TOO_MANY_REQUESTS`.

> Versi sisi server mengirim dengan secret key, sehingga Ikiform melewati **Allowed domains** dan pemeriksaan manusianya. Semua kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sebagai satu. 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.

### Komponen footer

`src/components/SignupForm.astro`:

```astro
---
import { actions } from "astro:actions";

interface Props {
  source?: "footer" | "post" | "newsletter-page";
}

const { source = "footer" } = Astro.props;
const target = new URL("/newsletter", Astro.url);
target.search = actions.subscribe.queryString;
---

<form class="signup" method="POST" action={target.pathname + target.search}>
  <label>Email <input name="email" type="email" required autocomplete="email" /></label>
  <label>First name (optional) <input name="first_name" maxlength="80" autocomplete="given-name" /></label>
  <label>
    <input name="consent" type="checkbox" required />
    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 />
  <p class="signup-status" role="status"></p>
  <button>Subscribe</button>
</form>

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

  for (const form of document.querySelectorAll<HTMLFormElement>("form.signup")) {
    const status = form.querySelector(".signup-status") as HTMLElement;
    const button = form.querySelector("button") as HTMLButtonElement;

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      button.disabled = true;
      status.textContent = "Subscribing";
      const { error } = await actions.subscribe(new FormData(form));
      button.disabled = false;
      if (!error) {
        form.replaceChildren(status);
        status.textContent = "Almost done. Open the email we sent you and confirm your address.";
        return;
      }
      const fields = isInputError(error) ? error.fields : {};
      status.textContent = fields.email?.[0] ?? fields.consent?.[0] ?? error.message;
    });
  }
</script>
```

- `queryString` adalah cara terdokumentasi untuk menyusun URL action. Mengaturnya pada URL `/newsletter` mengarahkan kiriman tanpa JavaScript ke halaman on demand alih-alih halaman statis asalnya.
- Astro mem-bundle `<script>` milik komponen sekali, berapa kali pun komponen itu muncul. Loop menghubungkan setiap salinan, seperti formulir footer dan satu lagi di akhir artikel.
- `isInputError` memisahkan pesan per field dari zod dari penolakan Ikiform, yang tiba sebagai pesan biasa.

### Halaman on demand

`src/pages/newsletter.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";
import SignupForm from "../components/SignupForm.astro";
import Base from "../layouts/Base.astro";

export const prerender = false;

const result = Astro.getActionResult(actions.subscribe);
const fields = isInputError(result?.error) ? result.error.fields : {};
const problem = fields.email?.[0] ?? fields.consent?.[0] ?? result?.error?.message;
---

<Base title="Newsletter">
  <h1>The monthly newsletter</h1>
  {result?.data?.subscribed ? (
    <p role="status">Almost done. Open the email we sent you and confirm your address.</p>
  ) : (
    <>
      {problem && <p role="alert">{problem}</p>}
      <SignupForm source="newsletter-page" />
    </>
  )}
</Base>
```

Astro menampilkan hasil action hanya untuk POST itu. Mengunjungi halaman lagi akan menghapusnya, dan itu tidak masalah untuk pendaftaran. Jika Anda lebih suka me-redirect ke URL terima kasih, dokumentasi Astro menunjukkan pola berbasis sesi dengan `getActionContext` di middleware.

## Teruskan subscriber ke MailerLite

Endpoint ini menerima webhook bertanda tangan dari Ikiform dan menambahkan setiap pendaftaran ke grup MailerLite sebagai `unconfirmed`, sehingga MailerLite mengirim konfirmasinya sendiri saat **Double opt-in for API and integrations** aktif. Endpoint membaca body sebagai teks lebih dulu karena tanda tangan mencakup byte persisnya. Endpoint mengirim setiap pendaftaran sebagai `unconfirmed`, yang cocok untuk daftar baru. Jika daftar Anda sudah punya pembaca terkonfirmasi, gunakan relay di [panduan MailerLite](https://ikiform.com/id/guides/newsletter-signup/mailerlite.md): relay itu mencari alamatnya lebih dulu, jadi pembaca terkonfirmasi yang mendaftar lagi mempertahankan statusnya.

`src/pages/api/ikiform.ts`:

```ts
import type { APIRoute } from "astro";
import { IKIFORM_WEBHOOK_SECRET, MAILERLITE_API_KEY, MAILERLITE_GROUP_ID } from "astro:env/server";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  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 answer = await fetch("https://connect.mailerlite.com/api/subscribers", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${MAILERLITE_API_KEY}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      email,
      fields: typeof first_name === "string" && first_name ? { name: first_name } : {},
      groups: [MAILERLITE_GROUP_ID],
      status: "unconfirmed",
    }),
  });

  if (answer.status === 429) return new Response("MailerLite rate limit", { status: 503, headers: { "retry-after": "60" } });
  if (!answer.ok) return new Response(`MailerLite answered ${answer.status}`, { status: 502 });
  return new Response(null, { status: 204 });
};
```

`security.checkOrigin` milik Astro, aktif secara default, menolak form post lintas situs ke route on demand. Webhook Ikiform berupa `application/json`, tipe yang tidak terkena pemeriksaan itu, jadi endpoint menerimanya apa adanya. Tambahkan URL di **Webhooks** dan salin secret `whsec_` ke `IKIFORM_WEBHOOK_SECRET`.

## Honeypot dan pemeriksaan manusia

Input `_gotcha` diteruskan dan Ikiform menyimpan yang terisi sebagai spam, dengan jawaban sukses dan tanpa webhook, jadi tidak pernah sampai ke MailerLite. Secret key melewati pemeriksaan Turnstile Ikiform. Jika bot lolos, Anda punya dua pilihan: verifikasi token Turnstile di handler, seperti yang ditunjukkan [panduan Turnstile](https://ikiform.com/id/guides/spam-protection/cloudflare-turnstile.md), atau hapus action dan kirim dari browser dengan `@ikiform/sdk`, yang mempertahankan pemeriksaan Ikiform, allowed domains, dan batas per pengunjung serta tidak butuh adapter sama sekali.

## Deploy

| Host | Yang harus dilakukan |
| --- | --- |
| Vercel | `@astrojs/vercel` mengubah kedua route on demand menjadi function. Tambahkan keempat variabel di **Environment Variables**. |
| Netlify | `@astrojs/netlify` melakukan hal yang sama dengan Netlify Functions. Tambahkan variabel di pengaturan environment situs. |
| Cloudflare | `@astrojs/cloudflare` menargetkan Workers; adapter ini tidak lagi mendukung Pages. Atur secret dengan `wrangler secret put`, dan gunakan `.dev.vars` secara lokal. Beri Worker domain kustom, karena Ikiform menolak URL webhook `workers.dev`. |

## Uji gratis di localhost

Workspace tanpa paket menerima 50 kiriman uji per bulan dari `localhost`. Jalankan `astro dev` dan `Origin` yang diteruskan adalah `http://localhost:4321`, jadi setiap pendaftaran dihitung sebagai uji. Coba kedua jalur: sekali secara normal, dan sekali dengan JavaScript dimatikan untuk melihat halaman `/newsletter`. Untuk menguji endpoint, ekspos lewat tunnel dengan URL HTTPS; Ikiform tidak akan mengirim ke `localhost`.

## Pastikan berfungsi

1. Jalankan `astro build` dan pastikan artikel Anda masih tercantum sebagai prerender di output, dengan hanya `/newsletter` dan `/api/ikiform` yang on demand.
2. Berlangganan dari footer sebuah artikel. Baris status berubah dan URL tidak.
3. Matikan JavaScript dan berlangganan lagi. Anda mendarat di `/newsletter` dengan baris periksa-inbox.
4. Kirim dengan kotak tidak dicentang dari dev tools. Pesan persetujuan tampil dan tidak ada yang disimpan.
5. Buka **Submissions** di dashboard. Kiriman baru ada di sana dengan setiap field yang Anda kirim.
6. Jika Anda menambahkan webhook, buka endpoint-nya di **Webhooks**. Pengiriman menampilkan `succeeded` dengan jawaban dari server Anda.
7. Buka MailerLite. Subscriber ada di grup sebagai unconfirmed, dan email konfirmasi ada di inbox Anda.

## Hal yang sering menjebak

| Masalah | Yang harus dilakukan |
| --- | --- |
| Kiriman tanpa JavaScript menampilkan 404 atau halaman statis | Formulir mengirim ke halaman prerender asalnya. Susun `action` dari `/newsletter` ditambah `queryString`, dan pertahankan `prerender = false` di halaman itu. |
| Hanya formulir pertama di halaman yang berfungsi | Script memakai `querySelector` untuk satu formulir. Astro menjalankannya sekali per halaman, jadi lakukan loop atas setiap `form.signup`. |
| Build gagal karena environment variable tidak ada | `astro:env` memeriksa schema saat build. Atur variabel di host, atau di `.env` secara lokal. |
| `402 plan_required` pada uji lokal | Kiriman tidak punya origin `localhost`. Periksa baris `Origin` di handler, 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

### Bisakah Astro Action berjalan di halaman prerender?

Jika dipanggil dari script klien, bisa, selama proyek punya adapter: action berjalan di route on demand-nya sendiri. Form post HTML biasa butuh halaman on demand sebagai tujuan, itulah sebabnya build ini mengirimnya ke /newsletter.

### Apakah saya butuh adapter untuk formulir newsletter di Astro?

Hanya untuk versi sisi server di halaman ini. Situs Astro yang sepenuhnya statis bisa mengirim formulir langsung dari browser ke Ikiform, yang mempertahankan pemeriksaan manusia Ikiform dan tidak butuh server.

### Mengapa meneruskan pendaftaran ke MailerLite lewat webhook alih-alih di action?

Action tidak bisa membedakan bot dari pembaca: Ikiform memberi keduanya jawaban sukses yang sama. Webhook-nya hanya terpicu untuk pendaftaran yang lolos pemeriksaan spam, dan gangguan MailerLite kemudian hanya menunda subscriber lewat percobaan ulang webhook alih-alih menggagalkan kiriman pengunjung.

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

- [Formulir pendaftaran newsletter](https://ikiform.com/id/guides/newsletter-signup.md): Buat formulir pendaftaran newsletter dengan markup Anda sendiri, catat persetujuan, blokir pendaftaran bot, dan teruskan setiap subscriber ke Kit, MailerLite, beehiiv, atau alat apa pun.
- [Situs statis](https://ikiform.com/id/guides/newsletter-signup/static-site.md): Tambahkan formulir pendaftaran newsletter ke situs statis Hugo, Jekyll, Eleventy, atau Astro, jauhkan API key alat email dari halaman, dan lakukan konfirmasi di alat itu.
- [MailerLite](https://ikiform.com/id/guides/newsletter-signup/mailerlite.md): Hubungkan formulir pendaftaran Anda sendiri ke MailerLite: relay menambahkan pembaca baru sebagai belum terkonfirmasi untuk double opt-in dan tidak pernah meminta subscriber mengonfirmasi dua kali.
- [Formulir newsletter Next.js](https://ikiform.com/id/guides/newsletter-signup/nextjs.md): Formulir newsletter di footer Next.js: server action dengan pemeriksaan persetujuan dan Turnstile, mengirim ke Ikiform, plus route handler yang menambahkan pembaca ke Kit.
- [Double opt-in](https://ikiform.com/id/guides/newsletter-signup/double-opt-in.md): Ikiform tidak mengirim email konfirmasi, jadi double opt-in berjalan di alat email Anda: relay menambahkan setiap pendaftaran sebagai belum terkonfirmasi dan alat itu mengirim tautannya.
- [Kit (ConvertKit)](https://ikiform.com/id/guides/newsletter-signup/kit.md): Hubungkan formulir newsletter Anda sendiri ke Kit dengan relay yang membuat subscriber, menambahkannya ke formulir Kit untuk incentive email, dan memberi tag penempatannya.
