Formulir lead capture

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.

Preet SutharPendiri, Ikiform

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.

Di mana Astro menjalankan setiap bagian formulir leadatribusikirimmiddlewareberjalan sekali,tanpapengunjungscriptFirstTouchmembaca ?utm_dan referrercookielead_sourcedikirim bersamakirimanPOSTformuliraction={actions.submitLead}Astro ActionZod, membacacookieAstro.redirectCal.com atau/thankshalaman samapesan di bawahsetiap fieldmengaturdata formulirresult.data.nextisInputErrorsaat builddi browsersesuai permintaanDi mana Astro menjalankan setiap bagian formulir leadatribusikirimmiddlewareberjalan sekali,tanpa pengunjungscriptFirstTouchmembaca ?utm_ danreferrercookielead_sourcedikirim bersamakirimanPOST formuliraction={actions.submitLead}Astro ActionZod, membaca cookieAstro.redirectCal.com atau /thankshalamansamapesan dibawahsetiapfieldmengaturdata formulirresult.data.nextisInputError
  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 membahas cara membuat formulir dan key.

file
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
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 fieldTipe di IkiformDiatur oleh
name, email, companyTeks, email, teksPengunjung
company_sizeSelect (pilihan): 1_10, 11_50, 51_200, 201_1000, 1000_plusPengunjung
budgetSelect (pilihan): under_10k, 10k_50k, 50k_plus, unsurePengunjung
timelineSelect (pilihan): this_quarter, next_quarter, later, researchingPengunjung
messageTextarea, opsionalPengunjung
utm_source, utm_medium, utm_campaign, referrer, landing_pageTeks, opsionalServer Anda, dari kunjungan pertama
fitSelect: qualified, nurtureServer 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 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
<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
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
---
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

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

  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

MasalahYang harus dilakukan
Formulir terkirim dan halaman tidak menampilkan hal baruHalaman kontak di-prerender. Tambahkan export const prerender = false ke halaman itu; dokumentasi Astro mewajibkan rendering on demand untuk form action.
Setiap field kosong setelah errorAstro 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 tagCookie 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 buildastro:env memvalidasi schema. Atur variabelnya di .env secara lokal dan di pengaturan host Anda.
422 validation_failedKiriman tidak cocok dengan definisi field formulir. error.fields menyebutkan setiap field dan alasannya. Samakan name input dengan ID field.

Pertanyaan

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