Formulir pendaftaran newsletter

Formulir pendaftaran newsletter7 menit baca

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.

Preet SutharPendiri, Ikiform

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

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:

JalurKapan berjalanYang dilihat pembaca
Script memanggil actions.subscribe()JavaScript termuat, kasus yang biasaBaris status di bawah formulir berubah. Mereka tetap di artikel
Formulir mengirim ke /newsletter?_action=...JavaScript mati atau belum termuatHalaman /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.

Dua cara pendaftaran dari footer Astro mencapai action yang samadengan JavaScripttanpa JavaScriptartikelHTMLprerenderactions.subscribe()dariscriptklienhandleractionzod,lalufetchbarisstatushalamansamaartikelkirimanformulirbiasa/newsletterprerender=falsehandleractionkodesamahalamanhasilgetActionResultFormData/_actions/subscribe{ data, error }POST?_actionhasilDua cara pendaftaran dari footer Astro mencapai action yang samadengan JavaScripttanpa JavaScriptartikelHTML prerenderactions.subscribe()dari script klienhandler actionzod, lalu fetchbaris statushalaman samaartikelkiriman formulir biasa/newsletterprerender = falsehandler actionkode samahalaman hasilgetActionResultFormData/_actions/subscribe{ data, error }POST?_actionhasil
  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
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
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
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 di tab Fields formulir.

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

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 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
---
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
---
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: relay itu mencari alamatnya lebih dulu, jadi pembaca terkonfirmasi yang mendaftar lagi mempertahankan statusnya.

src/pages/api/ikiform.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, 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

HostYang 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

MasalahYang harus dilakukan
Kiriman tanpa JavaScript menampilkan 404 atau halaman statisFormulir mengirim ke halaman prerender asalnya. Susun action dari /newsletter ditambah queryString, dan pertahankan prerender = false di halaman itu.
Hanya formulir pertama di halaman yang berfungsiScript memakai querySelector untuk satu formulir. Astro menjalankannya sekali per halaman, jadi lakukan loop atas setiap form.signup.
Build gagal karena environment variable tidak adaastro:env memeriksa schema saat build. Atur variabel di host, atau di .env secara lokal.
402 plan_required pada uji lokalKiriman 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 datangKiriman 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

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