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.
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.
- At build time, middleware runs for prerendered pages with no request data, so it can't record a visit.
- In the browser, the FirstTouch script reads the UTM tags and the referrer on the first page view.
- It stores them once in a lead_source cookie that every later request carries.
- The contact form posts to the action with a plain HTML form, no JavaScript needed.
- The action validates with Zod, reads the cookie and forwards the lead to Ikiform.
- The page reads the result and redirects to the scheduler or the thank-you page; input errors render under their fields.
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.
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" }),
},
},
});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 id | Type in Ikiform | Set by |
|---|---|---|
name, email, company | Text, email, text | The visitor |
company_size | Select: 1_10, 11_50, 51_200, 201_1000, 1000_plus | The visitor |
budget | Select: under_10k, 10k_50k, 50k_plus, unsure | The visitor |
timeline | Select: this_quarter, next_quarter, later, researching | The visitor |
message | Textarea, optional | The visitor |
utm_source, utm_medium, utm_campaign, referrer, landing_page | Text, optional | Your server, from the first visit |
fit | Select: qualified, nurture | Your server, after validation |
IKIFORM_SECRET_KEY in server-only environment variables.First touch on a static site
<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.tswithcontext.cookies.set(). Checkcontext.isPrerenderedfirst and skip.
The action
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 intonullbefore Zod sees it. An unchosenselecttherefore failsz.enumwith your own message, and optional text needsnullish(). - The browser's
Originheader is forwarded, because a server-side fetch sends none and Ikiform detects freelocalhosttests from it. Inastro devit ishttp://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
429becomesTOO_MANY_REQUESTS, so the page can tell a busy form from a bad input.
The contact page
---
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
| Host | What 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.
- Run
astro dev, open/pricing?utm_source=test&utm_campaign=launch, and check thelead_sourcecookie in dev tools. - Go to
/contact-sales, leave Budget empty and submit with JavaScript turned off. The page reloads with the budget error. - 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.
- Submit with Researching for now. You land on
/contact-sales/thanks. - Open Submissions in the dashboard and check
utm_source,referrer,landing_pageandfiton both leads. - 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
emailfield.
Gotchas
| Problem | What to do |
|---|---|
| The form posts and the page shows nothing new | The 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 error | Astro 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 tags | The 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 build | astro:env validates the schema. Set the variable in .env locally and in your host's settings. |
422 validation_failed | The 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
Yes, but not in middleware, which runs at build time for prerendered pages. A small script in your layout reads the tags and referrer on the first visit and stores them in a cookie, and the action reads the cookie when the lead submits.
Yes. A form with method POST and action set to the action posts the plain HTML way. The page must render on demand, and it reads the outcome with Astro.getActionResult().
Return the URL from the handler, then in the page's frontmatter check Astro.getActionResult() and return Astro.redirect() with that URL when there is no error. An absolute URL such as your Cal.com link works.
No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.