Waitlist for a new SaaS7 min read
How to build a Next.js waitlist with a server action
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.
Write a server action that checks the email, applies a per-visitor limit and forwards the signup to your form backend with a secret key. Call it through useActionState so errors come back as state, and wrap it with useOptimistic so the thank-you line shows the moment the button is pressed and rolls back if the post fails.
The main guide has a short Next.js tab: a server action and useActionState. That's enough for a quiet landing page. This page goes further, for a waitlist you're about to share widely: an instant thank-you, honest error messages, and a limit per visitor that a server-side post otherwise loses.
What a server action changes
A server action keeps the secret key on your server and lets you check the email before anything leaves your app. It also moves every post onto your server's IP address, and that has three effects:
| Item | Browser posts to Ikiform | Server action with a secret key |
|---|---|---|
| Who Ikiform sees | Each visitor's IP | Your server's IP, for every visitor |
| Per-visitor rate limit | Ikiform's, per IP | Yours to add, shown below |
| Allowed domains and the human check | Run | Skipped, because the key proves the post is yours |
| Who can call it | Anyone, from your allowed domains | Anyone who can POST to your page |
That last row matters. Next.js treats a server action as a public endpoint: anyone can call it with a crafted POST, not only your form. With the secret key skipping Ikiform's origin checks, your action is the only gate, so it needs its own limit.
- The visitor presses the button. useOptimistic swaps the form for the thank-you line at once.
- The server action checks the email and counts the visitor's recent tries by IP.
- Within the limit, it posts the signup to Ikiform with the secret key.
- The result becomes the action state. On success the thank-you stays; on an error the form comes back with the message and the typed email.
- Over the limit, nothing is posted and the error state comes back straight away.
Build it
The secret key
In the dashboard, open API keys, create a secret key with the submissions:write scope, and store it as a server-only variable. Never prefix it with NEXT_PUBLIC_.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY in server-only environment variables.A per-visitor limit
Five signups a minute per IP is generous for a person and useless for a script. This counter lives in the server's memory. server-only makes the build fail if a client component imports it.
import "server-only";
const WINDOW_MS = 60_000;
const LIMIT = 5;
const hits = new Map<string, number[]>();
export function allow(key: string, now = Date.now()) {
const recent = (hits.get(key) ?? []).filter((time) => now - time < WINDOW_MS);
const allowed = recent.length < LIMIT;
if (allowed) recent.push(now);
if (hits.size > 10_000) hits.clear();
hits.set(key, recent);
return allowed;
}- Memory isn't shared between server instances. On a single Node server or container this is exact. On serverless hosting, each instance keeps its own count, so treat it as a speed bump and move the counter to a shared store such as Redis when traffic grows.
- Only trust the IP header your host sets. Vercel sets
x-forwarded-for; behind Cloudflare, readcf-connecting-ip. A header the visitor can set themselves is no limit at all.
The server action
"use server";
import { headers } from "next/headers";
import { allow } from "@/lib/rate-limit";
export interface JoinState {
status: "idle" | "joined" | "error";
message: string;
email?: string;
}
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export async function joinWaitlist(_previous: JoinState, formData: FormData): Promise<JoinState> {
const email = String(formData.get("email") ?? "").trim();
if (!EMAIL.test(email)) return { status: "error", message: "Enter the email we should send your invite to.", email };
const incoming = await headers();
const visitor = incoming.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
if (!allow(visitor)) return { status: "error", message: "Too many tries from this connection. Wait a minute.", email };
let response: Response;
try {
response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
Origin: incoming.get("origin") ?? "",
},
body: JSON.stringify({
email,
ref: String(formData.get("ref") ?? "homepage").slice(0, 40),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
});
} catch {
return { status: "error", message: "We couldn't reach the list. Try again.", email };
}
if (response.status === 429) {
const wait = response.headers.get("Retry-After") ?? "60";
return { status: "error", message: `The list is busy. Try again in ${wait} seconds.`, email };
}
const result = await response.json().catch(() => null);
if (result?.ok) return { status: "joined", message: "You're on the list. We'll email you on launch day." };
return {
status: "error",
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
email,
};
}- The cheap check runs first. A bad email never costs a request.
AbortSignal.timeoutstops a slow network from holding the button in its pending state forever.- Ikiform's
429is read separately, with itsRetry-After, because with every visitor behind one IP it's the whole form that's busy, not this person. - Every error returns the email, so the input can show it again. React resets a form after an action, and
defaultValueputs the address back. - The browser's
Originheader is passed on because Ikiform counts free test submissions by it, and a server's ownfetchsends none. - The SDK's
submit()sends noAuthorizationheader, so it can't skip allowed domains. Usefetchwith the key here.
The form, with an optimistic thank-you
"use client";
import { useActionState, useOptimistic } from "react";
import { type JoinState, joinWaitlist } from "./actions";
const initial: JoinState = { status: "idle", message: "" };
export function WaitlistForm({ source }: { source: string }) {
const [state, dispatch, pending] = useActionState(joinWaitlist, initial);
const [shown, showJoined] = useOptimistic(
state,
(_current, email: string): JoinState => ({ status: "joined", message: `Adding ${email} to the list` }),
);
function join(formData: FormData) {
showJoined(String(formData.get("email") ?? ""));
dispatch(formData);
}
if (shown.status === "joined") {
return (
<p role="status" aria-busy={pending}>
{shown.message}
</p>
);
}
return (
<form action={join}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.email} />
<input type="hidden" name="ref" value={source} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{shown.status === "error" ? <p role="alert">{shown.message}</p> : null}
<button disabled={pending}>Join the waitlist</button>
</form>
);
}useOptimisticmust be set inside an action.joinis the form's action, so React runs it in a transition and the optimistic line holds untildispatchfinishes.- On success, the action state becomes
joinedand the line stays, now with the final message. On an error, the optimistic value falls away, the form comes back withstate.emailand the message. aria-busytells screen readers the line is still being confirmed.
join makes it a client function, so this form needs JavaScript. If posting before hydration matters more than the instant thank-you, pass dispatch straight to action and drop useOptimistic; the rest of the code stays the same.The page
import { WaitlistForm } from "./waitlist-form";
export default async function Page({ searchParams }: PageProps<"/waitlist">) {
const { ref } = await searchParams;
const source = typeof ref === "string" ? ref.slice(0, 40) : "homepage";
return (
<main>
<h1>Acme is almost here</h1>
<p>Leave your email and we'll send you an invite the day it opens.</p>
<WaitlistForm source={source} />
</main>
);
}The page reads ?ref= from the URL and hands it to the form, so a link such as /waitlist?ref=newsletter tags each signup with its source. PageProps is the route-aware type Next.js generates; reading searchParams renders the page per request.
Check it works
- Run
next dev, open/waitlist?ref=testand sign up. The thank-you line shows before the request finishes. - Open Submissions in the dashboard. The signup is there with
refset totest. - Submit six times within a minute. The sixth shows the limit message, and only five submissions are stored.
- Set
IKIFORM_SECRET_KEYto a wrong value and submit. The optimistic line rolls back and the form shows the error with your email still in it. - Check the inbox of each notification recipient. Each gets an email with the fields, and Reply goes to the submitter when the form has an
emailfield. - 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 |
|---|---|
403 forbidden from Ikiform | The key is from another workspace, revoked, or lacks submissions:write. Create a secret key with that scope. |
429 rate_limited | One visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait for Retry-After, or choose Relaxed or Custom for forms that post from one shared IP. |
| Every visitor hits the limit at once | The IP header is missing, so everyone counts as unknown. Read the header your host sets, such as cf-connecting-ip behind Cloudflare. |
| The thank-you flashes and the form comes back with no message | The action threw instead of returning state. Keep every failure path as a returned JoinState, as in the sample. |
| A warning that an optimistic update happened outside a transition | showJoined was called from an event handler. Call it inside the form's action, as join does. |
Questions
Either works. A browser post keeps Ikiform's per-visitor limits, allowed domains and human check. A server action hides the endpoint, checks input first and works before hydration, but you add your own per-visitor limit because every post comes from your server.
Yes. Set the optimistic value and call the dispatch function from useActionState inside the same action, such as the form's action prop. The optimistic value shows while the action runs and gives way to the action state when it finishes.
No. The server action forwards each signup to Ikiform, which stores it, filters spam, emails you and sends webhooks. Your app keeps no state beyond the rate-limit counter.
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.