Waitlist for a new SaaS

Waitlist for a new SaaS6 min read

How to add a waitlist form in plain HTML to any landing page

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.

Preet SutharFounder, Ikiform

Write a normal HTML form that posts the email to your Ikiform endpoint and redirects to a thank-you page, so it works on any host with no JavaScript at all. Then add a small script that posts the same form with fetch and shows the thank-you inline, and hands back to the plain post whenever the script can't finish, such as when the human check is on.

A waitlist page doesn't need a framework, a build step or a server. Four static files and a form backend cover it, and the page loads faster than anything a builder exports. This page builds the whole thing, layer by layer, so each layer works on its own.

What matters without a build step

  • The form is the integration. A plain <form> with an action is the whole connection to Ikiform. No package, no API key, nothing to compile.
  • JavaScript is an upgrade, not a requirement. The page should collect signups when the script is blocked, slow or broken. Each layer below adds to the one before it and falls back to it.
  • The script must never trap someone. If fetch fails or Ikiform asks for a human check that fetch can't run, the script hands the form back to the browser to post the normal way.
  • Hosting is anywhere. GitHub Pages, Netlify, Cloudflare Pages, an S3 bucket or a shared host all serve these files as they are.
LayerWhat it addsIf it's missing
HTML formPosts the email, redirects to thanks.htmlNothing works, so it comes first
CSSA readable page in light and dark modeA plain but working form
ScriptInline thank-you, field errors, no page changeThe plain post and redirect
The three paths a signup can take on a plain HTML waitlist pageNo JavaScriptScript loadedScript can't finishform posturlencodedIkiform303 redirectthanks.htmlyour own pagefetchAccept:application/jsonIkiform201 or an errorinline messagesame pagefetchnetwork error or acheck is neededform.submit()the plain postcheck page, thenthanks.html_redirectJSONfallbackThe three paths a signup can take on a plain HTML waitlist pageNo JavaScriptScript loadedScript can't finishform posturlencodedIkiform303 redirectthanks.htmlyour own pagefetchAccept: application/jsonIkiform201 or an errorinline messagesame pagefetchnetwork error or a check isneededform.submit()the plain postcheck page, thenthanks.html_redirectJSONfallback
  1. With JavaScript off, the browser posts the form, Ikiform redirects with 303 to your _redirect URL, and the visitor sees thanks.html.
  2. With the script loaded, it posts with fetch and asks for JSON. Ikiform answers 201 or an error, and the script shows the result on the same page.
  3. If fetch fails, or Ikiform answers challenge_required, the script calls form.submit().
  4. That plain post runs Ikiform's human check page if needed and ends on thanks.html.
Every path ends with a stored signup. The script only changes how the visitor sees it.

The files

The page

index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Acme is almost here</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main>
    <h1>Acme is almost here</h1>
    <p>Invoices that chase themselves. Leave your email and we'll invite you first.</p>

    <form id="waitlist" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
      <label for="email">Email</label>
      <div class="row">
        <input id="email" name="email" type="email" autocomplete="email" required placeholder="you@company.com">
        <button>Join the waitlist</button>
      </div>
      <input type="hidden" name="_redirect" value="https://example.com/thanks.html">
      <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden>
      <p class="message" role="status" aria-live="polite"></p>
    </form>
  </main>
  <script src="waitlist.js" defer></script>
</body>
</html>
  • _redirect sends the browser to thanks.html after a plain post. It only works for an https: URL on one of the form's Allowed domains, so add your domain there. Redirect after submit in the form's settings does the same without the hidden input.
  • The _gotcha input is the honeypot. People never see it; bots that fill every input are stored as spam.
  • aria-live="polite" makes screen readers announce the script's messages without moving focus.
  • The script tag uses defer, so it runs after the form exists and never blocks the page from drawing.

The enhancement

waitlist.js
const form = document.getElementById("waitlist");
const message = form.querySelector(".message");
const button = form.querySelector("button");
const key = crypto.randomUUID();

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  button.disabled = true;
  message.textContent = "Joining…";
  try {
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json", "Idempotency-Key": key },
      body: new FormData(form),
    });
    const result = await response.json();
    if (result.ok) {
      form.replaceChildren(Object.assign(document.createElement("p"), {
        textContent: "You're on the list. We'll email you on launch day.",
        role: "status",
      }));
      return;
    }
    if (result.error.code === "challenge_required") {
      form.submit();
      return;
    }
    const wait = response.headers.get("Retry-After");
    message.textContent = wait
      ? `Too many tries. Wait ${wait} seconds and try again.`
      : result.error.fields?.email?.[0] ?? result.error.message;
  } catch {
    form.submit();
    return;
  }
  button.disabled = false;
});
  • Accept: application/json makes Ikiform answer with JSON instead of a redirect, so the script can show the result in place.
  • The idempotency key is made once per page load. A double click, or a retry after a slow answer, stores one signup.
  • challenge_required means the form's spam check is set to Ikiform and fetch sent no token. form.submit() posts the plain way, which shows Ikiform's check page and then the thank-you page. It doesn't fire the submit event again, so there's no loop.
  • A network error takes the same fallback. If Ikiform was unreachable, the plain post shows the browser's own error, which is still more honest than a stuck button.
  • A 429 reads Retry-After and says how long to wait.

The thank-you page

thanks.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>You're on the list</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main>
    <h1>You're on the list</h1>
    <p>We'll email you the day Acme opens. Nothing before that.</p>
    <p><a href="/">Back to the page</a></p>
  </main>
</body>
</html>

The styles

style.css
body { margin: 0; font: 16px/1.5 system-ui, sans-serif; color: #111; background: #fff; }
main { max-width: 34rem; margin: 0 auto; padding: 18vh 1rem 4rem; }
h1 { font-size: 2.25rem; line-height: 1.1; margin: 0 0 0.75rem; }
label { display: block; font-weight: 600; margin: 1.5rem 0 0.375rem; }
.row { display: flex; flex-wrap: wrap; gap: 0.5rem; }
input[type="email"] { flex: 1 1 14rem; font: inherit; padding: 0.625rem 0.75rem; border: 1px solid #ccc; border-radius: 0.5rem; }
button { font: inherit; padding: 0.625rem 1rem; border: 0; border-radius: 0.5rem; background: #111; color: #fff; cursor: pointer; }
button:disabled { opacity: 0.6; cursor: progress; }
.message { min-height: 1.5em; margin: 0.5rem 0 0; }
@media (prefers-color-scheme: dark) {
  body { color: #eee; background: #111; }
  input[type="email"] { background: #1b1b1b; color: inherit; border-color: #444; }
  button { background: #eee; color: #111; }
}
If the page is on a different domain from example.com, update _redirect and the link back. Ikiform ignores a _redirect it can't follow and shows its own hosted thank-you page instead, so a mismatch never loses a signup.

Deploy and test

Put the four files in one folder and upload it to any static host. Then add the host's domain under Allowed domains: for example username.github.io, your-site.netlify.app or your own domain. While you work locally, add localhost as well; serve the folder with any static server, since a page opened as a file:// URL sends no origin.

Check it works

  1. Under the form's Allowed domains, add your site's domain, plus localhost while you develop. Leave it empty to accept posts from anywhere.
  2. Turn JavaScript off in the browser and sign up. You land on thanks.html.
  3. Turn it back on and sign up again. The form swaps for the thank-you line without leaving the page.
  4. Set Spam check to Ikiform and sign up once more. The page moves to the check, then to thanks.html.
  5. Open Submissions in the dashboard. The new submission is there with every field you sent.
  6. 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.

Gotchas

ProblemWhat to do
The browser lands on Ikiform's thank-you page instead of yours_redirect is ignored unless its domain is in Allowed domains and it is an https: URL. Add the domain, or set Redirect after submit in the form's settings instead.
403 origin_not_allowed, or every post lands in spamThe page's domain isn't in the form's Allowed domains. Add it, plus localhost for local tests. Subdomains of a listed domain are allowed.
Posts from a page opened as a file are refused or marked spamA file:// page sends no origin. Serve the folder with a local static server and add localhost to Allowed domains.
The thank-you line never shows, but signups arriveThe script didn't load: check the path in the src attribute, or that the host serves .js files. The plain post still works, which is why signups arrive.
402 test_limit_reachedThe workspace has no plan and used its 50 free localhost tests this month. Pick a plan, or use Send test in the dashboard.

The HTML forms docs list every reserved field, and the waitlist template generates a version with name and role. Exporting the list and the launch email are in the main guide.

Questions

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