Waitlist for a new SaaS8 min read
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.
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 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:
| Item | 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.
- The landing page is plain HTML from the CDN. When its script loads, it copies ?ref= or ?utm_source= into the hidden ref input.
- 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.
- The script swaps the form for a thank-you line, or shows the error under the input.
- Without JavaScript, the browser posts the form to /join, which renders on demand.
- /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.
Set up the project
Product setup, such as creating the form and the secret key, is in the Astro docs guide. The files for this page:
src/
actions/index.ts
components/WaitlistForm.astro
pages/index.astro
pages/join.astro
pages/thanks.astro
astro.config.mjs
.envAn 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.
npx astro add nodeimport { 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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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:
{
"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
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"parsesFormData. Empty inputs arrive asnull, sorefand the honeypot arenullish(), and a missingrefbecomesdirect.refmust be lowercase letters, digits,-or_, up to 40 characters. Anything else is stored asdirect, so a crafted link can't fill your export with junk. The limit matches themax_lengthin the definition.z.email()is Zod 4's top-level email schema, exported fromastro/zod. A bad address fails here and never reaches Ikiform.- The browser sends an
Originheader with both the script's call and the plain post. Ikiform counts free test submissions by that header and the action's ownfetchsends 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
ActionErrorwith a code that matches what went wrong. The script and/joinboth readerror.message.
The form component
---
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
actionis/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=newsletteris taggednewsletter. - 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
---
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:
---
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
- Run
astro devand openhttp://localhost:4321/?ref=test. Sign up. The form turns into the thank-you line without a reload. - Open Submissions in the dashboard. The signup is there with
refset totest. - Turn JavaScript off in the browser, reload and sign up again. You land on
/thanks, and the new signup hasrefset todirect. - Submit
not-an-emailwith JavaScript off./joinshows the form again with the email error. - 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. - Posts from
astro devcarry alocalhostorigin, 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 fromlocalhost. 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.
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
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.
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.
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.
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.