Formulir lead capture7 menit baca
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.
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 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.
- Saat build, middleware berjalan untuk halaman yang di-prerender tanpa data request, jadi tidak bisa mencatat kunjungan.
- Di browser, script FirstTouch membaca tag UTM dan referrer pada tampilan halaman pertama.
- Script menyimpannya sekali di cookie lead_source yang dibawa setiap request berikutnya.
- Formulir kontak mengirim ke action dengan formulir HTML biasa, tanpa perlu JavaScript.
- Action memvalidasi dengan Zod, membaca cookie, dan meneruskan lead ke Ikiform.
- Halaman membaca hasilnya dan me-redirect ke penjadwal atau halaman terima kasih; error input dirender di bawah field-nya.
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 membahas cara membuat formulir dan key.
src/
actions/index.ts
components/FirstTouch.astro
layouts/Base.astro
pages/
pricing.astro
contact-sales.astro
contact-sales/thanks.astro
astro.config.mjs
.envimport { 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 |
IKIFORM_SECRET_KEY di environment variable khusus server.First touch di situs statis
<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.tsdengancontext.cookies.set(). Periksacontext.isPrerendereddulu dan lewati.
Action-nya
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 menjadinullsebelum Zod melihatnya.selectyang tidak dipilih karenanya gagal diz.enumdengan pesan Anda sendiri, dan teks opsional butuhnullish(). - Header
Origindari browser diteruskan, karena fetch di sisi server tidak mengirimnya dan Ikiform mendeteksi ujilocalhostgratis dari header itu. Diastro devnilainyahttp://localhost:4321. - Action mengembalikan tujuan berikutnya alih-alih me-redirect sendiri. Action tidak bisa memanggil
redirect; halaman yang melakukannya dengan hasil itu. 429dari Ikiform menjadiTOO_MANY_REQUESTS, jadi halaman bisa membedakan formulir yang sibuk dari input yang buruk.
Halaman kontak
---
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.
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.
- Jalankan
astro dev, buka/pricing?utm_source=test&utm_campaign=launch, dan periksa cookielead_sourcedi dev tools. - Buka
/contact-sales, biarkan Budget kosong, dan kirim dengan JavaScript dimatikan. Halaman dimuat ulang dengan error anggaran. - 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.
- Kirim dengan Researching for now. Anda mendarat di
/contact-sales/thanks. - Buka Submissions di dashboard dan periksa
utm_source,referrer,landing_page, danfitpada kedua lead. - 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
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.
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().
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.
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.