# Build a waitlist in Astro

> An Astro waitlist on a static landing page: an Action forwards each signup, a script calls it, and one on-demand page catches posts without JavaScript.

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

Keep the landing page prerendered and put the server work in an Astro Action that validates the email and forwards it to your form backend with a secret key. A short script calls the action so the visitor stays on the page, and the form's plain `action` points at one on-demand page that runs the same action when JavaScript hasn't loaded.

The [main guide](https://ikiform.com/guides/waitlist.md#form) has a short Astro tab: one action, one form, and a page that renders on demand. That works, but it gives up the thing most people pick Astro for. A landing page is the page you most want served as static HTML from a CDN, and an Astro form action forces it to render on every request. This page keeps the landing page static and moves only the signup to the server.

## What's different in Astro

Astro prerenders every page by default. That has two effects on a waitlist that the other frameworks don't share:

|   | Prerendered landing page | Page rendered on demand |
| --- | --- | --- |
| Served from | A CDN, as a file | Your adapter's server or function |
| `<form action={actions.x}>` | Not allowed. Astro needs the page on demand | Works without JavaScript |
| `?ref=` in the URL | Not visible to the server. Built once, with no query | Readable from `Astro.url` |
| `Astro.clientAddress` | Unavailable | Available |

So the landing page can't use the form action pattern, and it can't read `?ref=` at build time. The fix is to split the jobs: the static page carries the form and a script, the action does the server work, and one small page, `/join`, renders on demand to catch a post that arrives without JavaScript.

Diagram: Two paths into one Astro Action from a static landing page

1. The landing page is plain HTML from the CDN. When its script loads, it copies ?ref= or ?utm_source= into the hidden ref input.
2. With JavaScript, the script stops the normal post and calls actions.joinWaitlist with the form data. The action runs on the server and forwards the signup to Ikiform.
3. The script swaps the form for a thank-you line, or shows the error under the input.
4. Without JavaScript, the browser posts the form to /join, which renders on demand.
5. /join calls the same action with Astro.callAction. On success it redirects to /thanks with a 303, so a refresh doesn't post again. On an error it shows the form with the message.

One action, two ways in. Only /join and the action itself need a server.

## Set up the project

Product setup, such as creating the form and the secret key, is in the [Astro docs guide](https://ikiform.com/docs/guides/astro.md). The files for this page:

`Project`:

```text
src/
  actions/index.ts
  components/WaitlistForm.astro
  pages/index.astro
  pages/join.astro
  pages/thanks.astro
astro.config.mjs
.env
```

### An adapter and a typed secret

Actions run on the server at request time, so the project needs an adapter even though most pages stay static. `astro add` installs one and edits the config. Swap `node` for `vercel`, `netlify` or `cloudflare` to match your host.

`Terminal`:

```bash
npx astro add node
```

`astro.config.mjs`:

```js
import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
    },
  },
});
```

`envField` with `access: "secret"` makes `IKIFORM_SECRET_KEY` importable only from `astro:env/server`. Import it in a client script and the build fails, which is the point. Create the key under **API keys** with the `submissions:write` scope.

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

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

### The field definition

The action sends `email` and `ref`. If you add a field definition on the form's **Fields** tab, it must list both, or Ikiform refuses the post with `422` and `unknown_field`:

`Fields`:

```json
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}
```

## Build it

### 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 = /^[a-z0-9_-]{1,40}$/;

export const server = {
  joinWaitlist: defineAction({
    accept: "form",
    input: z.object({
      email: z.email("Enter the email we should send your invite to."),
      ref: z
        .string()
        .nullish()
        .transform((value) => (value && SOURCE.test(value) ? value : "direct")),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      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({
          email: input.email,
          ref: input.ref,
          _gotcha: input._gotcha ?? "",
        }),
        signal: AbortSignal.timeout(8000),
      }).catch(() => null);

      if (!response) {
        throw new ActionError({ code: "GATEWAY_TIMEOUT", message: "We couldn't reach the list. Try again." });
      }
      if (response.status === 429) {
        throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Lots of people are joining. Try again in a minute." });
      }
      const result = await response.json().catch(() => null);
      if (result?.ok) return { joined: true };
      throw new ActionError({
        code: "BAD_REQUEST",
        message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      });
    },
  }),
};
```

- `accept: "form"` parses `FormData`. Empty inputs arrive as `null`, so `ref` and the honeypot are `nullish()`, and a missing `ref` becomes `direct`.
- `ref` must be lowercase letters, digits, `-` or `_`, up to 40 characters. Anything else is stored as `direct`, so a crafted link can't fill your export with junk. The limit matches the `max_length` in the definition.
- `z.email()` is Zod 4's top-level email schema, exported from `astro/zod`. A bad address fails here and never reaches Ikiform.
- The browser sends an `Origin` header with both the script's call and the plain post. Ikiform counts free test submissions by that header and the action's own `fetch` sends none, so the action passes it on.
- The honeypot goes through untouched, so Ikiform still marks bots that fill it as spam. Posts with the secret key skip Ikiform's human check, so turning **Spam check** on adds nothing here; the action is the gate.
- Each failure throws an `ActionError` with a code that matches what went wrong. The script and `/join` both read `error.message`.

### The form component

`src/components/WaitlistForm.astro`:

```astro
---
interface Props {
  error?: string;
}

const { error = "" } = Astro.props;
---

<form method="POST" action="/join" data-waitlist>
  <label>
    Email
    <input name="email" type="email" autocomplete="email" required placeholder="you@company.com" />
  </label>
  <input type="hidden" name="ref" value="direct" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <p role="alert">{error}</p>
  <button>Join the waitlist</button>
</form>

<script>
  import { actions, isInputError } from "astro:actions";

  const query = new URLSearchParams(location.search);
  const source = (query.get("ref") ?? query.get("utm_source") ?? "").toLowerCase().slice(0, 40);

  for (const form of document.querySelectorAll<HTMLFormElement>("form[data-waitlist]")) {
    const ref = form.elements.namedItem("ref") as HTMLInputElement;
    const alert = form.querySelector<HTMLElement>("[role=alert]")!;
    const button = form.querySelector("button")!;
    if (source) ref.value = source;

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      button.disabled = true;
      button.textContent = "Joining";
      alert.textContent = "";

      const { error } = await actions.joinWaitlist(new FormData(form));
      if (!error) {
        const done = document.createElement("p");
        done.setAttribute("role", "status");
        done.textContent = "You're on the list. We'll email you on launch day.";
        form.replaceWith(done);
        return;
      }
      alert.textContent = isInputError(error) ? (error.fields.email?.[0] ?? "Check the address.") : error.message;
      button.disabled = false;
      button.textContent = "Join the waitlist";
    });
  }
</script>
```

- The form's `action` is `/join`, a normal URL. Without JavaScript the browser posts there, so the form works before the script loads or if it fails.
- The script reads `?ref=` in the browser, because a prerendered page was built with no query string. A visitor from `/?ref=newsletter` is tagged `newsletter`.
- Astro bundles a component's `<script>` once per page, so the loop wires every waitlist form on it. Put one in the hero and one in the footer and both work.
- The label wraps the input instead of pointing at an `id`, so two copies on a page don't clash.

### The landing page and /join

`src/pages/index.astro`:

```astro
---
import WaitlistForm from "../components/WaitlistForm.astro";
---

<main>
  <h1>Acme is almost here</h1>
  <p>Leave your email and we'll send you an invite the day it opens.</p>
  <WaitlistForm />
</main>
```

No `prerender` export, so it stays static. `/join` opts out and handles the plain post:

`src/pages/join.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";
import WaitlistForm from "../components/WaitlistForm.astro";

export const prerender = false;

let error = "";
if (Astro.request.method === "POST") {
  const result = await Astro.callAction(actions.joinWaitlist, await Astro.request.formData());
  if (!result.error) return Astro.redirect("/thanks", 303);
  error = isInputError(result.error) ? (result.error.fields.email?.[0] ?? "Check the address.") : result.error.message;
  Astro.response.status = 400;
}
---

<main>
  <h1>Join the Acme waitlist</h1>
  <WaitlistForm error={error} />
</main>
```

`Astro.callAction` runs the action with the same validation, so there is one place to change. The `303` sends the browser to `/thanks` with a GET, which is a static page you write with the same thank-you line.

## Deploy notes

| Host | Adapter | The secret |
| --- | --- | --- |
| Vercel | `npx astro add vercel` | Add `IKIFORM_SECRET_KEY` under the project's **Environment Variables** |
| Netlify | `npx astro add netlify` | Add it under **Site configuration › Environment variables** |
| Cloudflare | `npx astro add cloudflare` | Add it as a secret in the Worker's settings, or with `wrangler secret put IKIFORM_SECRET_KEY` |

On every host, the landing page and `/thanks` go out as static files, and the action plus `/join` become one server function. A quiet day costs almost nothing, and a launch spike mostly hits the CDN.

## Check it works

1. Run `astro dev` and open `http://localhost:4321/?ref=test`. Sign up. The form turns into the thank-you line without a reload.
2. Open **Submissions** in the dashboard. The signup is there with `ref` set to `test`.
3. Turn JavaScript off in the browser, reload and sign up again. You land on `/thanks`, and the new signup has `ref` set to `direct`.
4. Submit `not-an-email` with JavaScript off. `/join` shows the form again with the email error.
5. Post with the honeypot filled in, `_gotcha=bot`. It still gets a success answer, but the submission is stored as spam and sends no email or webhook.
6. Posts from `astro dev` carry a `localhost` origin, so they count as test submissions. 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).

## Gotchas

| Problem | What to do |
| --- | --- |
| `403` from your own site on the plain post | Astro's `security.checkOrigin`, on by default, refuses form posts to on-demand pages from another origin. Post from the same host the site runs on, such as `localhost:4321` in dev, not through a different preview domain. |
| A build error about form actions on a prerendered page | A form uses `action={actions.joinWaitlist}` on a static page. Keep the action as `/join`, as above, or add `export const prerender = false` to that page. |
| `ref` is always `direct` | The link uses a value with capitals or spaces, or the script didn't load. The script lowercases the value; anything left outside `a-z`, `0-9`, `-` and `_` is dropped by the action. |
| `422` with `unknown_field` | The form's field definition doesn't list `ref`. Add it as in the definition above. |
| `402 plan_required` | The workspace has no plan and the post came from a live site. It is held, not lost. Pick a plan or buy a pack under **Settings › Plan and usage** and held submissions arrive. |

## Questions

### Can an Astro waitlist page stay static?

Yes. Leave the landing page prerendered, call the action from a client script, and point the form's plain action at one page that renders on demand. Only the action and that page need a server.

### Do Astro Actions need an adapter?

Yes. Actions run on the server when they're called, so the project needs an adapter such as Node, Vercel, Netlify or Cloudflare, even if every page is prerendered.

### Why not post the Astro form straight to Ikiform?

You can, and it keeps Ikiform's per-visitor rate limit and human check. The action is worth it when you want to validate and clean the source tag first, or keep the endpoint out of your page's HTML.

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

- [Waitlist for a new SaaS](https://ikiform.com/guides/waitlist.md): Build a pre-launch waitlist on your own landing page: the form, spam protection, duplicates, confirmation emails and a clean export on launch day.
- [Next.js waitlist](https://ikiform.com/guides/waitlist/nextjs.md): A Next.js App Router waitlist: a server action that forwards signups with a secret key, useActionState, an optimistic thank-you and a per-visitor limit.
- [Plain HTML waitlist](https://ikiform.com/guides/waitlist/html.md): A complete no-build waitlist page in plain HTML that works with JavaScript off, shows an inline thank-you when it loads, and falls back when it must.
- [Product Hunt launch list](https://ikiform.com/guides/waitlist/product-hunt.md): Collect a launch list weeks before Product Hunt, tag each signup by channel, and send the launch-day email in two waves timed to the Pacific Time reset.
- [Beta signup with screening](https://ikiform.com/guides/waitlist/beta-signup.md): A beta application form with screening questions the server enforces, and a script that scores applicants and picks each cohort from the list.
- [Mobile app waitlist](https://ikiform.com/guides/waitlist/mobile-app.md): A pre-launch waitlist for an iOS or Android app that asks which phone people use, sends iPhone testers to TestFlight and fills a Play closed test.
