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

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

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](https://ikiform.com/guides/lead-capture.md#form) 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.

Diagram: Where Astro runs each part of a lead form

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](https://ikiform.com/docs/guides/astro.md) covers creating the form and the key.

`files`:

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

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 |

> 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](https://ikiform.com/docs/rate-limits.md) 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`:

```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`:

```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`:

```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](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md).

## 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](https://ikiform.com/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

| 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

### Can I track UTM parameters on a static Astro site?

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.

### Does an Astro form action work without JavaScript?

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

### How do I redirect after an Astro Action succeeds?

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.

### Is Ikiform free?

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.

## More guides

- [Lead capture form](https://ikiform.com/guides/lead-capture.md): A lead capture form on your own site that emails you with reply-to, pings team chat and feeds your CRM, so you answer while the lead is still there.
- [Next.js lead form](https://ikiform.com/guides/lead-capture/nextjs.md): A Next.js App Router lead form that validates with Zod in a server action, keeps first-touch UTMs in a cookie and sends qualified leads to your calendar.
- [SvelteKit lead form](https://ikiform.com/guides/lead-capture/sveltekit.md): A SvelteKit lead form: the handle hook records first-touch UTMs, a form action validates with Valibot, and redirect() sends qualified leads to a calendar.
- [Demo request form](https://ikiform.com/guides/lead-capture/demo-request.md): A book-a-demo form that stores the lead, then sends the visitor to Cal.com or Calendly with their name and email already filled in.
- [Quote request form](https://ikiform.com/guides/lead-capture/quote-request.md): A request-a-quote form that collects briefs as shared links instead of uploads, puts a due date on every request, and keeps link-heavy leads out of spam.
- [Consultation request form](https://ikiform.com/guides/lead-capture/consultation-request.md): A consultation request form for accountants, lawyers, coaches and clinics that asks how and when to call, and leaves sensitive details for the call.
