Waitlist for a new SaaS

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.

Preet SutharFounder, Ikiform

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:

ItemBrowser posts to IkiformServer action with a secret key
Who Ikiform seesEach visitor's IPYour server's IP, for every visitor
Per-visitor rate limitIkiform's, per IPYours to add, shown below
Allowed domains and the human checkRunSkipped, because the key proves the post is yours
Who can call itAnyone, from your allowed domainsAnyone 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.

How a signup moves through the Next.js server actionformuseOptimisticshows thethank-youserveractionchecks email,limits by IPIkiformsecret key,stores thesignupactionstatejoined, or anerrorerrorstatenothing isposted, theform comesbackFormDataJSONresultover the limitHow a signup moves through the Next.js server actionformuseOptimistic showsthe thank-youserver actionchecks email, limitsby IPIkiformsecret key, stores thesignupaction statejoined, or an errorerrorstatenothing isposted, theform comesbackFormDataJSONresultover the limit
  1. The visitor presses the button. useOptimistic swaps the form for the thank-you line at once.
  2. The server action checks the email and counts the visitor's recent tries by IP.
  3. Within the limit, it posts the signup to Ikiform with the secret key.
  4. 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.
  5. Over the limit, nothing is posted and the error state comes back straight away.
The optimistic line lasts only while the action runs. The action state decides what stays on screen.

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_.

.env.local
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.

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.

lib/rate-limit.ts
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, read cf-connecting-ip. A header the visitor can set themselves is no limit at all.

The server action

app/waitlist/actions.ts
"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.timeout stops a slow network from holding the button in its pending state forever.
  • Ikiform's 429 is read separately, with its Retry-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 defaultValue puts the address back.
  • The browser's Origin header is passed on because Ikiform counts free test submissions by it, and a server's own fetch sends none.
  • The SDK's submit() sends no Authorization header, so it can't skip allowed domains. Use fetch with the key here.

The form, with an optimistic thank-you

app/waitlist/waitlist-form.tsx
"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>
  );
}
  • useOptimistic must be set inside an action. join is the form's action, so React runs it in a transition and the optimistic line holds until dispatch finishes.
  • On success, the action state becomes joined and the line stays, now with the final message. On an error, the optimistic value falls away, the form comes back with state.email and the message.
  • aria-busy tells screen readers the line is still being confirmed.
Wrapping the action in 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

app/waitlist/page.tsx
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

  1. Run next dev, open /waitlist?ref=test and sign up. The thank-you line shows before the request finishes.
  2. Open Submissions in the dashboard. The signup is there with ref set to test.
  3. Submit six times within a minute. The sixth shows the limit message, and only five submissions are stored.
  4. Set IKIFORM_SECRET_KEY to a wrong value and submit. The optimistic line rolls back and the form shows the error with your email still in it.
  5. 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 email field.
  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
403 forbidden from IkiformThe key is from another workspace, revoked, or lacks submissions:write. Create a secret key with that scope.
429 rate_limitedOne 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 onceThe 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 messageThe 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 transitionshowJoined was called from an event handler. Call it inside the form's action, as join does.

Questions

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