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.
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 anactionis 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
fetchfails or Ikiform asks for a human check thatfetchcan'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.
| Layer | What it adds | If it's missing |
|---|---|---|
| HTML form | Posts the email, redirects to thanks.html | Nothing works, so it comes first |
| CSS | A readable page in light and dark mode | A plain but working form |
| Script | Inline thank-you, field errors, no page change | The plain post and redirect |
- With JavaScript off, the browser posts the form, Ikiform redirects with 303 to your _redirect URL, and the visitor sees thanks.html.
- 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.
- If fetch fails, or Ikiform answers challenge_required, the script calls form.submit().
- That plain post runs Ikiform's human check page if needed and ends on thanks.html.
The files
The page
<!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>_redirectsends the browser tothanks.htmlafter a plain post. It only works for anhttps: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
_gotchainput 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
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/jsonmakes 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_requiredmeans the form's spam check is set to Ikiform andfetchsent no token.form.submit()posts the plain way, which shows Ikiform's check page and then the thank-you page. It doesn't fire thesubmitevent 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
429readsRetry-Afterand says how long to wait.
The thank-you page
<!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
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; }
}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
- Under the form's Allowed domains, add your site's domain, plus
localhostwhile you develop. Leave it empty to accept posts from anywhere. - Turn JavaScript off in the browser and sign up. You land on
thanks.html. - Turn it back on and sign up again. The form swaps for the thank-you line without leaving the page.
- Set Spam check to Ikiform and sign up once more. The page moves to the check, then to
thanks.html. - Open Submissions in the dashboard. The new submission is there with every field you sent.
- 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
| Problem | What 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 spam | The 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 spam | A 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 arrive | The 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_reached | The 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
Yes. A plain HTML form posts the email to your Ikiform endpoint and Ikiform redirects the browser to your thank-you page. JavaScript only adds the inline message.
Anywhere that serves static files: GitHub Pages, Netlify, Cloudflare Pages, S3 or a shared host. The form posts to Ikiform, so the host never runs any code.
Ikiform answers 403 challenge_required. The script in this guide catches that and submits the form the plain way, which shows the check page and then your thank-you page.
No. Ikiform has no hosted form pages. You put the form on your own site, in any builder or framework, and point it at your Ikiform endpoint.
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.