DEV Community

ShipInAnHour
ShipInAnHour

Posted on

AI-built waitlist forms in Next.js 16: 6 traps to check before launch (and the prompts that fix them)

An AI-built Next.js waitlist form can look finished and still lose signups, let a script skip its rate limit, and put emails in the URL, so run these six checks before you share the link. The prompts and commands below are ready to copy.

The setup is the usual first version for a launch page: a Client Component form that POSTs JSON to a Route Handler (app/api/waitlist/route.ts), which writes to a small JSON file. Versions: Next.js 16.4.2, React 19.3, Node 20. If the scaffold itself is fighting you (cacheComponents build errors, Tailwind v4 dark mode), that's in the earlier post on Next.js 16 + Tailwind v4 traps.

1. The "loading" check isn't a lock

The standard guard looks like this:

async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();
  if (status === "loading") return; // reads state from this render
  setStatus("loading");
  await fetch("/api/waitlist", { /* ... */ });
}
Enter fullscreen mode Exit fullscreen mode

For a real double-click, this plus disabled={status === "loading"} holds up. In a Playwright check, a double-click and pressing Enter twice each sent one request. But status is captured by this render's closure, so two submits in the same task both see "idle". Calling form.requestSubmit() twice in a row sent two POSTs. Extensions, autofill scripts and your own tests can do exactly that.

A ref changes immediately, so make it the lock and keep the state for the UI:

const inFlight = useRef(false);

async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();
  if (inFlight.current) return;
  inFlight.current = true;
  setStatus("loading");
  try {
    // fetch + handle response
  } finally {
    inFlight.current = false;
  }
}
Enter fullscreen mode Exit fullscreen mode

With that change all three checks sent one POST, and npm run lint still reported 0 problems.

If you move to a Server Action with useActionState, the React docs say "React queues and executes multiple calls to dispatchAction sequentially." A second submit gets queued, not dropped. Disable the button with isPending, and make the server safe to call twice anyway.

The waitlist form must not send two requests for one signup. Use a useRef in-flight flag as the lock (set before fetch, cleared in finally) and keep status state only for the button label and disabled. The API must return the same result if the same email is posted twice.

2. Duplicate signups and the file store race

A prompt that only says "save signups to a JSON file" leaves room for the simplest version: read the file, push, write the file. That's fine for one request at a time. With a plain fs/promises version and no lock, 20 concurrent signups with 20 different emails left one row in the file, in three runs out of three. Every request read the same empty array and the last write won.

The same email twice at the same moment is worse: both requests reported "created", each with a different referral code, and only one was saved. Whoever got the losing response now shares a referral link that points at nothing.

For a single-server file store, serialize every read-modify-write and write atomically:

let queue: Promise<unknown> = Promise.resolve();
function withLock<T>(fn: () => Promise<T>): Promise<T> {
  const run = queue.then(fn, fn);
  queue = run.catch(() => undefined);
  return run;
}

// inside withLock: read → find existing by email → push → write to .tmp → rename
Enter fullscreen mode Exit fullscreen mode

With the lock, eight parallel POSTs of the same email produced one row and one referral code. Normalize before comparing (email.trim().toLowerCase()), so " [email protected] " matches [email protected], and decide what a duplicate returns. A 200 with the existing spot is friendlier than an error. In our test runs the duplicate checkpoint returned alreadyJoined: true with the same code.

One tradeoff: that friendly response also tells anyone who types a stranger's email that the person is on your list, and hands over their referral code. For a pre-launch waitlist that may be fine. If it isn't, return the same "Check your inbox" message for new and existing emails and send the spot by email.

On Postgres, let the database enforce it. The Postgres docs note that a unique index on lower(email) rejects values that differ only in case:

CREATE UNIQUE INDEX signups_email_lower_idx ON signups (lower(email));
Enter fullscreen mode Exit fullscreen mode

3. Validation and spam checks that only exist in the browser

type="email" and a regex in the component are UX, not protection. Your Route Handler is a public URL, and so is a Server Action: the Next.js data security guide says a Server Action "is reachable via a direct POST request, not just through your application's UI," and tells you to always validate client input. So the server repeats every check: string type, trimmed, at most 254 characters, a basic pattern, and an optional ref that must match your code format.

A honeypot is cheap. Add a field people never see, and if it's filled, return a normal-looking success without saving:

<div aria-hidden="true" className="absolute -left-[9999px]">
  <label htmlFor="company">Company</label>
  <input id="company" name="company" type="text" tabIndex={-1} autoComplete="off" />
</div>
Enter fullscreen mode Exit fullscreen mode

It only catches bots that fill every field, so you still want a rate limit, and that's where the first version gets fragile. The quick version, and the one in our test app, is an in-memory Map keyed by the first value of x-forwarded-for. Two problems:

  • That header is whatever the client sends unless a proxy you control overwrites it. A different X-Forwarded-For on each request got 12 of 12 signups through a 10-per-minute limit. MDN's header reference lists "rate-limiter avoidance" as a consequence of trusting untrusted values.
  • The Map lives in one process. It resets on restart, and each extra instance gets its own.

Try it with the app running on port 3000:

for i in $(seq 1 12); do
  curl -s -o /dev/null -w "%{http_code} " -X POST localhost:3000/api/waitlist \
    -H 'content-type: application/json' -H "x-forwarded-for: 10.0.0.$i" \
    -d "{\"email\":\"t$i@test.com\"}"
done; echo
Enter fullscreen mode Exit fullscreen mode

Twelve 200s means the limit is decorative. Read the client IP only from the header your host's proxy sets, and move the counter to shared storage once you run more than one instance. Leave headroom for your own checkpoints, too. In our test runs, 5 per minute tripped the curl checkpoint and was raised to 10.

Treat app/api/waitlist/route.ts as a public endpoint. Re-validate everything on the server, add a hidden company honeypot that returns a fake success without saving, and rate limit per IP. Only trust the client IP from the header my host's proxy sets. Note in the README that an in-memory limiter resets on restart and isn't shared across instances.

4. Storage that disappears on deploy

A file under process.cwd()/data works on your laptop, a VPS, or a container with a volume. It doesn't on serverless hosts. Vercel's runtime docs describe a "read-only filesystem with writable /tmp scratch space," and their files guide recommends object storage when you need to write.

Put the decision in the prompt: keep storage behind a few functions (readSignups, addSignup, deleteSignup) in one server-only module, so moving to a hosted database touches one file. Keep /data/ in .gitignore so test emails never land in your repo.

5. Secrets that end up in client JavaScript

Next.js inlines any NEXT_PUBLIC_ variable into the browser bundle at build time, and the value is frozen after next build. Variables without the prefix stay on the server. When a Client Component can't read a value, the tempting fix, for you or the assistant, is to add the prefix, which ships that key to every visitor.

Check after a build:

npm run build
grep -rl "$(grep ADMIN_PASSWORD .env.local | cut -d= -f2)" .next/static && echo "LEAKED" || echo "not in client bundle"
Enter fullscreen mode Exit fullscreen mode

On the test app the admin password was in 0 client files, and the public site URL, which is meant to be public, was in 1. For a hard guarantee, the Next.js docs suggest import "server-only" at the top of storage and auth modules, so importing them from a Client Component fails the build. One more from our test runs: the default .env* rule in .gitignore also hides .env.example, so add !.env.example if you want to commit one.

6. The success state and accessibility

Three things to check after the form "works":

  1. Focus. When the success card replaces the form, the focused input is gone and focus drops to <body>. Move it to the message:
   const doneRef = useRef<HTMLParagraphElement>(null);
   useEffect(() => {
     if (status === "done") doneRef.current?.focus();
   }, [status]);
   // <p ref={doneRef} tabIndex={-1}>You're #12 on the list</p>
Enter fullscreen mode Exit fullscreen mode
  1. Live regions. MDN says to establish the live region before updating its content, ideally in the initial markup. An aria-live card that only mounts on success may not be announced. Keep one always-rendered message element with aria-live="polite" for errors and success, and point the input's aria-describedby at it.

  2. Before hydration. In our test runs, one smoke-test failure came from a click that landed before the page hydrated. That matters because a <form> without method submits as GET: with JavaScript disabled, the test app navigated to /?email=early%40test.com&company=. That's an email in the URL, browser history, and maybe your analytics. At minimum add method="post". If the form should actually work before hydration, the React docs note that useActionState with a Server Function can show the server's response before hydration completes.

One prompt to audit what you have

If the form already exists, paste this before launch:

Audit my waitlist form and API, and fix only what fails: (1) two synchronous requestSubmit() calls send one request; use a ref lock. (2) 20 concurrent POSTs with different emails save 20 rows, and the same email posted twice at once saves one row with one referral code. (3) the server re-validates email (trim, lowercase, ≤254 chars). (4) a hidden company honeypot returns fake success without saving. (5) changing X-Forwarded-For can't bypass the rate limit. (6) no secret appears in .next/static after npm run build. (7) after success, focus moves to the message and a persistent aria-live element announces errors and success. (8) the <form> has method="post". Show me the command or test you used for each item, then run npm run lint and npm run build.

Asking for the command behind each item matters more than the list itself. The AI will happily report "rate limiting added" for a limiter that one header walks right past.

If you want the setup prompts that come before all this, the free sample has four prompts from our Landing Page + Waitlist pack: a build-safe Next.js 16 scaffold, a Tailwind v4 dark-mode toggle, and rescues for build failures and invisible colors or gradients.

Top comments (1)

Collapse
 
elijahbrown profile image
Elijah Brown •

Really useful list, and the 12 of 12 through a changed X-Forwarded-For header is a good test to make people run. One thing I'd add to #2 now that each signup gets a referral code: trim and lowercase won't stop [email protected], [email protected] and [email protected] from all saving as separate rows, and Gmail delivers all of them to the same inbox. If referrals move people up the list, that's the easiest way to climb it. Keeping a second key for the uniqueness check (plus tag stripped, and dots removed for Gmail) while storing the address as typed closes most of it. I'd also only count a referral once the referred person has confirmed their email, so the honeypot and rate limit aren't the only things between a script and the top of the list.