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

Published 2026-10-25 · https://ikiform.com/guides/waitlist/nextjs

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](https://ikiform.com/guides/waitlist.md#form) 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:

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

Diagram: How a signup moves through the Next.js server action

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`:

```bash
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](https://ikiform.com/docs/rate-limits.md) 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`:

```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`:

```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`:

```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`:

```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

| 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

### Should a Next.js waitlist post from the browser or from a server action?

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.

### Does useOptimistic work with useActionState?

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.

### Do I need a database for a Next.js waitlist?

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.

### Is Ikiform free?

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.

## More guides

- [Waitlist for a new SaaS](https://ikiform.com/guides/waitlist.md): Build a pre-launch waitlist on your own landing page: the form, spam protection, duplicates, confirmation emails and a clean export on launch day.
- [Plain HTML waitlist](https://ikiform.com/guides/waitlist/html.md): 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.
- [Beta signup with screening](https://ikiform.com/guides/waitlist/beta-signup.md): A beta application form with screening questions the server enforces, and a script that scores applicants and picks each cohort from the list.
- [Product Hunt launch list](https://ikiform.com/guides/waitlist/product-hunt.md): Collect a launch list weeks before Product Hunt, tag each signup by channel, and send the launch-day email in two waves timed to the Pacific Time reset.
- [Mobile app waitlist](https://ikiform.com/guides/waitlist/mobile-app.md): A pre-launch waitlist for an iOS or Android app that asks which phone people use, sends iPhone testers to TestFlight and fills a Play closed test.
- [Waitlist on Framer](https://ikiform.com/guides/waitlist/framer.md): Two ways to collect waitlist signups on Framer: Framer's form with a webhook to Ikiform, or a code component that keeps per-visitor limits and spam checks.
