Waitlist for a new SaaS

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.

Preet SutharFounder, Ikiform

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:

ItemPrerendered landing pagePage rendered on demand
Served fromA CDN, as a fileYour adapter's server or function
<form action={actions.x}>Not allowed. Astro needs the page on demandWorks without JavaScript
?ref= in the URLNot visible to the server. Built once, with no queryReadable from Astro.url
Astro.clientAddressUnavailableAvailable

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.

Two paths into one Astro Action from a static landing pageWith JavaScriptWithout JavaScriptstaticpagescriptfills reffrom theURLactions.joinWaitlist()runs on theserverIkiformsecret key,stores itthank-youlinesame pagestaticpageform poststo /join/joinon demand,Astro.callActionIkiformthe sameaction/thanks303redirectFormDataJSONresultPOSTJSON303Two paths into one Astro Action from a static landing pageWith JavaScriptWithout JavaScriptstatic pagescript fills ref from the URLactions.joinWaitlist()runs on the serverIkiformsecret key, stores itthank-you linesame pagestatic pageform posts to /join/joinon demand, Astro.callActionIkiformthe same action/thanks303 redirectFormDataJSONresultPOSTJSON303
  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. The files for this page:

Project
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
npx astro add node
astro.config.mjs
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
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 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
{
  "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
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
---
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
---
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
---
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

HostAdapterThe secret
Vercelnpx astro add vercelAdd IKIFORM_SECRET_KEY under the project's Environment Variables
Netlifynpx astro add netlifyAdd it under Site configuration › Environment variables
Cloudflarenpx astro add cloudflareAdd 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.

Gotchas

ProblemWhat to do
403 from your own site on the plain postAstro'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 pageA 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 directThe 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_fieldThe form's field definition doesn't list ref. Add it as in the definition above.
402 plan_requiredThe 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

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