Lead capture form

Lead capture form7 min read

Build a qualifying lead form in Astro

An Astro lead form built on Actions: Zod validation, first-touch UTMs on a static site, an on-demand contact page and a redirect to your scheduler.

Preet SutharFounder, Ikiform

Keep your marketing pages prerendered and record the first visit's UTM tags and referrer in a small cookie from a client script, because middleware on a prerendered page only runs at build time. Render the contact page on demand, validate the lead in an Astro Action with astro/zod, and forward it with the attribution to your form backend. Return the next URL from the action and call Astro.redirect() to send qualified leads to your scheduler.

The Astro tab on the main guide posts a lead through an action and shows a thank-you line. This page turns it into a sales form: company size, budget and timeline checked by Zod on your server, campaign data that survives a static site, and a hand-off to your calendar for leads that fit.

What Astro changes

Astro prerenders pages by default, and that shapes everything about attribution. Middleware runs for a prerendered page once, at build time, with no visitor, no cookies and no query string. The landing page where ?utm_source= actually appears is almost always prerendered, so middleware never sees the tags.

So the first touch is recorded in the browser instead, by a short script in your layout, and read back on the server by the action. The contact page is the one page that must render on demand, because the Astro docs require it for any page that calls an action from a form.

Where Astro runs each part of a lead formattributionsubmitmiddlewareruns once, novisitorFirstTouchscriptreads ?utm_ andreferrerlead_sourcecookiesent with thepostform POSTaction={actions.submitLead}Astro ActionZod, readscookieAstro.redirectCal.com or/thankssame pagemessage undereach fieldsetsform dataresult.data.nextisInputErrorbuild timein the browseron demandWhere Astro runs each part of a lead formattributionsubmitmiddlewareruns once, novisitorFirstTouchscriptreads ?utm_ andreferrerlead_sourcecookiesent with the postform POSTaction={actions.submitLead}Astro ActionZod, reads cookieAstro.redirectCal.com or /thankssamepagemessageunder eachfieldsetsform dataresult.data.nextisInputError
  1. At build time, middleware runs for prerendered pages with no request data, so it can't record a visit.
  2. In the browser, the FirstTouch script reads the UTM tags and the referrer on the first page view.
  3. It stores them once in a lead_source cookie that every later request carries.
  4. The contact form posts to the action with a plain HTML form, no JavaScript needed.
  5. The action validates with Zod, reads the cookie and forwards the lead to Ikiform.
  6. The page reads the result and redirects to the scheduler or the thank-you page; input errors render under their fields.
Only the contact page needs a server. Everything a visitor reads before it stays static.

Project setup

Add an adapter with npx astro add vercel (or netlify, or cloudflare), then declare the key with astro:env so Astro refuses to build without it and never ships it to the browser. The Astro docs page covers creating the form and the key.

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

With no output set, every page stays prerendered and only pages that export prerender = false run on the server. Add the fields below on the form's Fields tab, so Ikiform checks the same rules and rejects anything else.

Field idType in IkiformSet by
name, email, companyText, email, textThe visitor
company_sizeSelect: 1_10, 11_50, 51_200, 201_1000, 1000_plusThe visitor
budgetSelect: under_10k, 10k_50k, 50k_plus, unsureThe visitor
timelineSelect: this_quarter, next_quarter, later, researchingThe visitor
messageTextarea, optionalThe visitor
utm_source, utm_medium, utm_campaign, referrer, landing_pageText, optionalYour server, from the first visit
fitSelect: qualified, nurtureYour server, after validation
The server-side versions post with a secret key, so Ikiform skips Allowed domains and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom rate limit for a form you post through a server, and keep IKIFORM_SECRET_KEY in server-only environment variables.

First touch on a static site

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>
  • Put <FirstTouch /> in the layout every page uses. Astro bundles the script once and runs it on each page load; the cookie check makes only the first visit count.
  • The cookie isn't httpOnly, because a script sets it. Treat it as a hint: the action reads only five known keys and Ikiform caps every field's length.
  • A referrer from your own host is ignored, so clicking from Pricing to Contact doesn't overwrite the real source.
  • If your landing pages render on demand instead, the same logic fits in src/middleware.ts with context.cookies.set(). Check context.isPrerendered first and skip.

The action

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 })}` };
    },
  }),
};
  • With accept: "form", Astro turns an empty input into null before Zod sees it. An unchosen select therefore fails z.enum with your own message, and optional text needs nullish().
  • The browser's Origin header is forwarded, because a server-side fetch sends none and Ikiform detects free localhost tests from it. In astro dev it is http://localhost:4321.
  • The action returns where to go next instead of redirecting itself. Actions can't call redirect; the page does it with the result.
  • Ikiform's 429 becomes TOO_MANY_REQUESTS, so the page can tell a busy form from a bad input.

The contact page

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>

The form posts the plain HTML way, so it works with JavaScript off. Astro runs the action, renders this same page with the result, and the frontmatter either redirects with a 303 or renders the errors. isInputError separates Zod's per-field messages from the action's own failures. The small script only disables the button, so a double click can't send the lead twice.

Honeypot and human check

The honeypot is part of the schema and is forwarded, so Ikiform stores a filled one as spam, and the action sends it to the thank-you page rather than your calendar. The secret key skips Ikiform's own human check. If bots find the page, verify a Turnstile token in the handler, as in Turnstile on a form.

Deploy notes

HostWhat to know
Vercel@astrojs/vercel runs the contact page and actions as functions and serves the rest as static files. Add IKIFORM_SECRET_KEY in the project's environment variables.
Netlify@astrojs/netlify does the same with Netlify Functions. Set the key in the site's environment variables.
Cloudflare@astrojs/cloudflare runs on-demand pages in a Worker. Store the key as a secret with wrangler secret put; astro:env reads it at runtime.

Check it works

Posts from astro dev carry a localhost origin. Ikiform has no free plan. Testing is free: a workspace without a plan accepts 50 test submissions a month from localhost. To go live, pick Pro from $6/mo for 500 submissions, or pay once: 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire. See pricing.

  1. Run astro dev, open /pricing?utm_source=test&utm_campaign=launch, and check the lead_source cookie in dev tools.
  2. Go to /contact-sales, leave Budget empty and submit with JavaScript turned off. The page reloads with the budget error.
  3. Submit as a qualified lead: 11 to 50 people, more than $50k, next quarter. You land on Cal.com with name and email filled in.
  4. Submit with Researching for now. You land on /contact-sales/thanks.
  5. Open Submissions in the dashboard and check utm_source, referrer, landing_page and fit on both leads.
  6. Check the inbox of each notification recipient. Each gets an email with the fields, and Reply goes to the submitter when the form has an email field.

Gotchas

ProblemWhat to do
The form posts and the page shows nothing newThe contact page is prerendered. Add export const prerender = false to it; the Astro docs require on-demand rendering for form actions.
Every field is empty after an errorAstro clears inputs on a form submission. To keep them, enable view transitions with <ClientRouter /> and add transition:persist to each input.
UTM fields are empty although the link had tagsThe cookie already existed from an earlier visit, or the layout with <FirstTouch /> isn't used on the landing page. Clear the cookie and try again.
IKIFORM_SECRET_KEY is missing at buildastro:env validates the schema. Set the variable in .env locally and in your host's settings.
422 validation_failedThe post doesn't match the form's field definition. error.fields names each field and the reason. Match the input name to the field id.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.