# Buat formulir lead dengan kualifikasi di Astro

> Formulir lead Astro berbasis Actions: validasi Zod, UTM first-touch di situs statis, halaman kontak on demand, dan redirect ke penjadwal Anda.

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

Biarkan halaman marketing Anda di-prerender dan catat tag UTM serta referrer dari kunjungan pertama di cookie kecil lewat script klien, karena middleware di halaman yang di-prerender hanya berjalan saat build. Render halaman kontak on demand, validasi lead di Astro Action dengan `astro/zod`, dan teruskan bersama atribusinya ke backend formulir Anda. Kembalikan URL berikutnya dari action dan panggil `Astro.redirect()` untuk mengarahkan lead yang lolos kualifikasi ke penjadwal Anda.

[Tab Astro di panduan utama](https://ikiform.com/id/guides/lead-capture.md#form) mengirim lead lewat action dan menampilkan baris terima kasih. Halaman ini mengubahnya menjadi formulir sales: ukuran perusahaan, anggaran, dan jadwal diperiksa Zod di server Anda, data kampanye yang bertahan di situs statis, dan penyerahan ke kalender Anda untuk lead yang cocok.

## Apa yang diubah Astro

Astro melakukan prerender halaman secara default, dan itu membentuk segalanya tentang atribusi. Middleware berjalan untuk halaman yang di-prerender sekali saja, saat build, tanpa pengunjung, tanpa cookie, dan tanpa query string. Landing page tempat `?utm_source=` benar-benar muncul hampir selalu di-prerender, jadi middleware tidak pernah melihat tag-nya.

Jadi first touch dicatat di browser, oleh script pendek di layout Anda, dan dibaca kembali di server oleh action. Halaman kontak adalah satu-satunya halaman yang harus dirender on demand, karena dokumentasi Astro mewajibkannya untuk setiap halaman yang memanggil action dari formulir.

Diagram: Di mana Astro menjalankan setiap bagian formulir lead

1. Saat build, middleware berjalan untuk halaman yang di-prerender tanpa data request, jadi tidak bisa mencatat kunjungan.
2. Di browser, script FirstTouch membaca tag UTM dan referrer pada tampilan halaman pertama.
3. Script menyimpannya sekali di cookie lead_source yang dibawa setiap request berikutnya.
4. Formulir kontak mengirim ke action dengan formulir HTML biasa, tanpa perlu JavaScript.
5. Action memvalidasi dengan Zod, membaca cookie, dan meneruskan lead ke Ikiform.
6. Halaman membaca hasilnya dan me-redirect ke penjadwal atau halaman terima kasih; error input dirender di bawah field-nya.

Hanya halaman kontak yang butuh server. Semua yang dibaca pengunjung sebelumnya tetap statis.

## Setup proyek

Tambahkan adapter dengan `npx astro add vercel` (atau `netlify`, atau `cloudflare`), lalu deklarasikan key dengan `astro:env` agar Astro menolak build tanpanya dan tidak pernah mengirimkannya ke browser. [Halaman dokumentasi Astro](https://ikiform.com/id/docs/guides/astro.md) membahas cara membuat formulir dan key.

`file`:

```text
src/
  actions/index.ts
  components/FirstTouch.astro
  layouts/Base.astro
  pages/
    pricing.astro
    contact-sales.astro
    contact-sales/thanks.astro
astro.config.mjs
.env
```

`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" }),
    },
  },
});
```

Tanpa `output` diatur, setiap halaman tetap di-prerender dan hanya halaman yang mengekspor `prerender = false` yang berjalan di server. Tambahkan field di bawah di tab **Fields** formulir, agar Ikiform memeriksa aturan yang sama dan menolak selebihnya.

| ID field | Tipe di Ikiform | Diatur oleh |
| --- | --- | --- |
| `name`, `email`, `company` | Teks, email, teks | Pengunjung |
| `company_size` | Select (pilihan): `1_10`, `11_50`, `51_200`, `201_1000`, `1000_plus` | Pengunjung |
| `budget` | Select (pilihan): `under_10k`, `10k_50k`, `50k_plus`, `unsure` | Pengunjung |
| `timeline` | Select (pilihan): `this_quarter`, `next_quarter`, `later`, `researching` | Pengunjung |
| `message` | Textarea, opsional | Pengunjung |
| `utm_source`, `utm_medium`, `utm_campaign`, `referrer`, `landing_page` | Teks, opsional | Server Anda, dari kunjungan pertama |
| `fit` | Select: `qualified`, `nurture` | Server Anda, setelah validasi |

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

## First touch di situs statis

`src/components/FirstTouch.astro`:

```astro
<script>
  const TAGS = ["utm_source", "utm_medium", "utm_campaign"];

  if (!document.cookie.split("; ").some((pair) => pair.startsWith("lead_source="))) {
    const params = new URLSearchParams(location.search);
    const from = URL.canParse(document.referrer) ? new URL(document.referrer) : null;
    const external = from && from.host !== location.host ? from.href.slice(0, 300) : "";
    const source = new URLSearchParams();
    for (const tag of TAGS) source.set(tag, (params.get(tag) ?? "").slice(0, 100));

    if (external || TAGS.some((tag) => source.get(tag))) {
      source.set("referrer", external);
      source.set("landing_page", location.pathname.slice(0, 200));
      document.cookie = `lead_source=${encodeURIComponent(source.toString())}; path=/; max-age=2592000; samesite=lax`;
    }
  }
</script>
```

- Taruh `<FirstTouch />` di layout yang dipakai setiap halaman. Astro mem-bundle script sekali dan menjalankannya di setiap pemuatan halaman; pengecekan cookie membuat hanya kunjungan pertama yang dihitung.
- Cookie ini tidak `httpOnly`, karena diatur oleh script. Anggap sebagai petunjuk: action hanya membaca lima key yang dikenal dan Ikiform membatasi panjang setiap field.
- Referrer dari host Anda sendiri diabaikan, jadi mengklik dari **Pricing** ke **Contact** tidak menimpa sumber aslinya.
- Jika landing page Anda dirender on demand, logika yang sama bisa ditaruh di `src/middleware.ts` dengan `context.cookies.set()`. Periksa `context.isPrerendered` dulu dan lewati.

## 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 = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];

export const server = {
  submitLead: defineAction({
    accept: "form",
    input: z.object({
      name: z.string().trim().min(1, "Tell us who to reply to.").max(120),
      email: z.email("Enter a work email we can reply to."),
      company: z.string().trim().min(1, "Which company is this for?").max(200),
      company_size: z.enum(["1_10", "11_50", "51_200", "201_1000", "1000_plus"], { error: "Pick a company size." }),
      budget: z.enum(["under_10k", "10k_50k", "50k_plus", "unsure"], { error: "Pick a budget range." }),
      timeline: z.enum(["this_quarter", "next_quarter", "later", "researching"], { error: "Pick a timeline." }),
      message: z.string().trim().max(5000).nullish(),
      _gotcha: z.string().nullish(),
    }),
    handler: async (lead, context) => {
      const stored = new URLSearchParams(context.cookies.get("lead_source")?.value ?? "");
      const source = Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""]));
      const qualified =
        lead.company_size !== "1_10" &&
        ["10k_50k", "50k_plus"].includes(lead.budget) &&
        ["this_quarter", "next_quarter"].includes(lead.timeline);

      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({ ...lead, ...source, fit: qualified ? "qualified" : "nurture" }),
      });
      const result = await response.json().catch(() => null);
      if (!result?.ok) {
        throw new ActionError({
          code: response.status === 429 ? "TOO_MANY_REQUESTS" : "BAD_REQUEST",
          message: result?.error?.message ?? "We couldn't send that. Email sales@example.com instead.",
        });
      }

      if (!qualified || lead._gotcha) return { next: "/contact-sales/thanks" };
      return { next: `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}` };
    },
  }),
};
```

- Dengan `accept: "form"`, Astro mengubah input kosong menjadi `null` sebelum Zod melihatnya. `select` yang tidak dipilih karenanya gagal di `z.enum` dengan pesan Anda sendiri, dan teks opsional butuh `nullish()`.
- Header `Origin` dari browser diteruskan, karena fetch di sisi server tidak mengirimnya dan Ikiform mendeteksi uji `localhost` gratis dari header itu. Di `astro dev` nilainya `http://localhost:4321`.
- Action mengembalikan tujuan berikutnya alih-alih me-redirect sendiri. Action tidak bisa memanggil `redirect`; halaman yang melakukannya dengan hasil itu.
- `429` dari Ikiform menjadi `TOO_MANY_REQUESTS`, jadi halaman bisa membedakan formulir yang sibuk dari input yang buruk.

## Halaman kontak

`src/pages/contact-sales.astro`:

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

export const prerender = false;

const result = Astro.getActionResult(actions.submitLead);
if (result && !result.error) return Astro.redirect(result.data.next, 303);

const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
const failure = result?.error && !isInputError(result.error) ? result.error.message : "";
---

<Base title="Talk to sales">
  <form method="POST" action={actions.submitLead}>
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required />
    {fields.name && <p role="alert">{fields.name[0]}</p>}

    <label for="email">Work email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    {fields.email && <p role="alert">{fields.email[0]}</p>}

    <label for="company">Company</label>
    <input id="company" name="company" autocomplete="organization" required />
    {fields.company && <p role="alert">{fields.company[0]}</p>}

    <label for="company_size">Company size</label>
    <select id="company_size" name="company_size" required>
      <option value="">Choose one</option>
      <option value="1_10">1 to 10</option>
      <option value="11_50">11 to 50</option>
      <option value="51_200">51 to 200</option>
      <option value="201_1000">201 to 1,000</option>
      <option value="1000_plus">More than 1,000</option>
    </select>
    {fields.company_size && <p role="alert">{fields.company_size[0]}</p>}

    <label for="budget">Budget</label>
    <select id="budget" name="budget" required>
      <option value="">Choose one</option>
      <option value="under_10k">Under $10k</option>
      <option value="10k_50k">$10k to $50k</option>
      <option value="50k_plus">More than $50k</option>
      <option value="unsure">Not sure yet</option>
    </select>
    {fields.budget && <p role="alert">{fields.budget[0]}</p>}

    <label for="timeline">When do you want to start?</label>
    <select id="timeline" name="timeline" required>
      <option value="">Choose one</option>
      <option value="this_quarter">This quarter</option>
      <option value="next_quarter">Next quarter</option>
      <option value="later">Later this year</option>
      <option value="researching">Researching for now</option>
    </select>
    {fields.timeline && <p role="alert">{fields.timeline[0]}</p>}

    <label for="message">Anything we should know?</label>
    <textarea id="message" name="message" rows="4"></textarea>

    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {failure && <p role="alert">{failure}</p>}
    <button>Talk to sales</button>
  </form>
</Base>

<script>
  const form = document.querySelector("form");
  form?.addEventListener("submit", () => {
    const button = form.querySelector("button");
    if (button) {
      button.disabled = true;
      button.textContent = "Sending";
    }
  });
</script>
```

Formulir mengirim dengan cara HTML biasa, jadi bekerja saat JavaScript mati. Astro menjalankan action, merender halaman yang sama dengan hasilnya, dan frontmatter me-redirect dengan `303` atau merender error-nya. `isInputError` memisahkan pesan per field dari Zod dari kegagalan action itu sendiri. Script kecil hanya menonaktifkan tombol, jadi klik ganda tidak bisa mengirim lead dua kali.

### Honeypot dan pemeriksaan manusia

Honeypot adalah bagian dari schema dan diteruskan, jadi Ikiform menyimpan yang terisi sebagai spam, dan action mengarahkannya ke halaman terima kasih, bukan kalender Anda. Secret key melewati pemeriksaan manusia milik Ikiform. Jika bot menemukan halaman ini, verifikasi token Turnstile di handler, seperti di [Turnstile di formulir](https://ikiform.com/id/guides/spam-protection/cloudflare-turnstile.md).

## Catatan deploy

| Host | Yang perlu diketahui |
| --- | --- |
| Vercel | `@astrojs/vercel` menjalankan halaman kontak dan action sebagai function dan menyajikan sisanya sebagai file statis. Tambahkan `IKIFORM_SECRET_KEY` di environment variable proyek. |
| Netlify | `@astrojs/netlify` melakukan hal yang sama dengan Netlify Functions. Atur key di environment variable situs. |
| Cloudflare | `@astrojs/cloudflare` menjalankan halaman on demand di Worker. Simpan key sebagai secret dengan `wrangler secret put`; `astro:env` membacanya saat runtime. |

## Pastikan berfungsi

Kiriman dari `astro dev` membawa origin `localhost`. 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).

1. Jalankan `astro dev`, buka `/pricing?utm_source=test&utm_campaign=launch`, dan periksa cookie `lead_source` di dev tools.
2. Buka `/contact-sales`, biarkan **Budget** kosong, dan kirim dengan JavaScript dimatikan. Halaman dimuat ulang dengan error anggaran.
3. Kirim sebagai lead yang lolos kualifikasi: 11 sampai 50 orang, lebih dari $50rb, kuartal depan. Anda mendarat di Cal.com dengan nama dan email terisi.
4. Kirim dengan **Researching for now**. Anda mendarat di `/contact-sales/thanks`.
5. Buka **Submissions** di dashboard dan periksa `utm_source`, `referrer`, `landing_page`, dan `fit` pada kedua lead.
6. Cek inbox setiap penerima notifikasi. Masing-masing mendapat email berisi field, dan **Reply** terkirim ke pengirim formulir jika formulir punya field `email`.

## Hal yang sering menjebak

| Masalah | Yang harus dilakukan |
| --- | --- |
| Formulir terkirim dan halaman tidak menampilkan hal baru | Halaman kontak di-prerender. Tambahkan `export const prerender = false` ke halaman itu; dokumentasi Astro mewajibkan rendering on demand untuk form action. |
| Setiap field kosong setelah error | Astro mengosongkan input saat formulir dikirim. Untuk mempertahankannya, aktifkan view transitions dengan `<ClientRouter />` dan tambahkan `transition:persist` ke setiap input. |
| Field UTM kosong padahal tautannya punya tag | Cookie sudah ada dari kunjungan sebelumnya, atau layout dengan `<FirstTouch />` tidak dipakai di landing page. Hapus cookie dan coba lagi. |
| `IKIFORM_SECRET_KEY is missing` saat build | `astro:env` memvalidasi schema. Atur variabelnya di `.env` secara lokal dan di pengaturan host Anda. |
| `422 validation_failed` | Kiriman tidak cocok dengan definisi field formulir. `error.fields` menyebutkan setiap field dan alasannya. Samakan `name` input dengan ID field. |

## Pertanyaan

### Bisakah saya melacak parameter UTM di situs Astro statis?

Bisa, tetapi tidak di middleware, yang berjalan saat build untuk halaman yang di-prerender. Script kecil di layout Anda membaca tag dan referrer pada kunjungan pertama dan menyimpannya di cookie, dan action membaca cookie itu saat lead dikirim.

### Apakah form action Astro bekerja tanpa JavaScript?

Ya. Formulir dengan method POST dan action diatur ke action tersebut mengirim dengan cara HTML biasa. Halaman harus dirender on demand, dan membaca hasilnya dengan Astro.getActionResult().

### Bagaimana cara me-redirect setelah Astro Action berhasil?

Kembalikan URL dari handler, lalu di frontmatter halaman periksa Astro.getActionResult() dan kembalikan Astro.redirect() dengan URL itu saat tidak ada error. URL absolut seperti tautan Cal.com Anda bisa dipakai.

### 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 lead capture](https://ikiform.com/id/guides/lead-capture.md): Formulir lead capture di situs Anda sendiri yang mengirimi Anda email dengan reply-to, memberi tahu chat tim, dan mengisi CRM, agar Anda menjawab selagi lead masih ada.
- [Formulir lead Next.js](https://ikiform.com/id/guides/lead-capture/nextjs.md): Formulir lead App Router Next.js yang memvalidasi dengan Zod di server action, menyimpan UTM first-touch di cookie, dan mengirim lead yang lolos kualifikasi ke kalender Anda.
- [Formulir lead SvelteKit](https://ikiform.com/id/guides/lead-capture/sveltekit.md): Formulir lead SvelteKit: hook handle mencatat UTM first-touch, form action memvalidasi dengan Valibot, dan redirect() mengarahkan lead yang lolos kualifikasi ke kalender.
- [Formulir permintaan demo](https://ikiform.com/id/guides/lead-capture/demo-request.md): Formulir pesan demo yang menyimpan lead, lalu mengarahkan pengunjung ke Cal.com atau Calendly dengan nama dan email yang sudah terisi.
- [Formulir permintaan penawaran](https://ikiform.com/id/guides/lead-capture/quote-request.md): Formulir minta penawaran yang mengumpulkan brief sebagai tautan bersama alih-alih unggahan, memberi tenggat pada setiap permintaan, dan menjauhkan lead yang penuh tautan dari spam.
- [Formulir permintaan konsultasi](https://ikiform.com/id/guides/lead-capture/consultation-request.md): Formulir permintaan konsultasi untuk akuntan, pengacara, coach, dan klinik yang menanyakan cara dan waktu menghubungi, dan menyimpan detail sensitif untuk panggilannya.
