Skip to main content
ASoc
Tutorial

A Waitlist Landing Page Is One Server Action and a Honeypot

This storefront's real waitlist form: the Resend Audience API's deprecated field a copy-pasted snippet would miss, and firing one analytics event per successful subscribe, not per render.

The ASoc Team8 min read

A waitlist landing page needs exactly one working part: a form that turns an email address into a row in a mailing list, with nothing in between that could silently lose it. This storefront ships one — subscribeToWaitlist, a Server Action wired to Resend's Audience API — and its real value isn't the UI, which is a single input and a button. It's the failure handling: a honeypot that eats spam without telling the bot it worked, a validation regex shared with the contact form, and a documented trap in the vendor API itself that a copy-pasted "send to Resend" snippet would walk straight into.

What a waitlist page actually needs, versus what most guides ship

Generic adviceThis storefront's implementation
FieldsEmail, sometimes name, sometimes a referral codeEmail only — one honeypot field, hidden
Spam defenseA CAPTCHA widgetA hidden company field; a filled one returns a fake success silently
Where the email goes"Connect an ESP"Resend's /contacts endpoint, into one Audience
Success feedbackA generic "thanks!"useActionState's per-submission state, driving both the message and a fired analytics event
What's trackedNothing, usuallyA typed waitlist_joined conversion event, fired once per successful submit

Most "waitlist landing page" advice is about layout — hide the nav, one CTA, a position counter — because the tools it assumes (Waitlister, LaunchList, a hosted embed) already handle the plumbing. Building the plumbing yourself, on your own domain, with your own ESP, is the part that has actual failure modes worth getting right.

The Server Action, in full

// src/lib/actions/newsletter.ts
"use server";
import { EMAIL_RE } from "@/lib/validation";

export type FormState = { ok: boolean; message: string } | null;

export async function subscribeToWaitlist(
  _prev: FormState,
  formData: FormData,
): Promise<FormState> {
  // Honeypot: real users never fill this hidden field.
  if (formData.get("company"))
    return { ok: true, message: "You're on the list!" };

  const email = String(formData.get("email") ?? "")
    .trim()
    .toLowerCase();
  if (!EMAIL_RE.test(email)) {
    return { ok: false, message: "Please enter a valid email address." };
  }

  const apiKey = process.env.RESEND_API_KEY;
  const audienceId = process.env.RESEND_AUDIENCE_ID;
  if (!apiKey || !audienceId) {
    console.error("waitlist: RESEND_API_KEY / RESEND_AUDIENCE_ID not configured");
    return { ok: false, message: "Something went wrong — please try again later." };
  }

  try {
    const res = await fetch("https://api.resend.com/contacts", {
      method: "POST",
      headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
      body: JSON.stringify({ email, unsubscribed: false, audience_id: audienceId }),
    });
    if (!res.ok) {
      console.error("waitlist: resend error", res.status, await res.text());
      return { ok: false, message: "Something went wrong — please try again later." };
    }
    return { ok: true, message: "You're on the list! We'll email you at launch." };
  } catch (err) {
    console.error("waitlist: network error", err);
    return { ok: false, message: "Something went wrong — please try again later." };
  }
}

Three things worth reading past the happy path. First, the honeypot returns { ok: true } for a filled company field — a bot that fills every input sees a normal success message and moves on, rather than a rejection that would tell it to try harder. Second, every failure path — missing env vars, a non-2xx from Resend, a thrown network error — returns the same generic message to the visitor while logging the specific cause server-side, so a misconfigured deploy fails safe (a polite error) instead of surfacing an API key problem to a stranger on the internet. Third: audience_id in that request body is a field Resend's own OpenAPI spec marks deprecated in favor of Segments, but the audience-scoped /audiences/{id}/contacts path it used to require is also deprecated — audience_id in the flat /contacts body is the still-working transitional form. A guide written from Resend's current landing-page docs alone, without reading the OpenAPI spec, would either use the older nested path (which still works, for now) or miss that this field has a sunset date at all.

Firing the conversion exactly once

The client side is a thin useActionState wrapper, and the interesting line is the useEffect that reports success:

// src/components/molecules/NewsletterForm.tsx
const [state, formAction, pending] = useActionState(subscribeToWaitlist, null);

useEffect(() => {
  if (state?.ok) track("waitlist_joined");
}, [state]);

useActionState hands back a brand-new state object on every submission — not a mutated one — so the effect's dependency array sees a genuinely new reference each time and re-runs once per submit, never on an unrelated re-render that happens to touch this component. Firing the track() call inside the action itself (rather than the effect) would double-count on React Strict Mode's development double-invoke, or under any retry logic added later; keeping it in an effect gated on the result object's identity ties one conversion event to one successful state transition, not to one call site. waitlist_joined is one of five typed events in this app's ConversionEvent union — the analytics-comparison post covers the full union and why per-post, per-target attribution beats a session count; this post only adds the one thing that post doesn't cover, which is how a single event fires exactly once per real subscribe rather than per render.

What this isn't, honestly

This storefront's waitlist form is the site-wide signup in the footer — general "tell us when there's something new," not a per-product gate on any of the catalog's 26 coming-soon templates specifically. A product page in coming-soon status disables its buy button and edition rows; it doesn't currently render its own waitlist CTA. That's a real gap between what this post can demonstrate and the keyword's most literal reading (a page selling one specific unreleased thing), and it's stated here rather than glossed over: the code below is exactly what a per-product waitlist would reuse — same action, same honeypot, same Resend call — just invoked from a product page instead of the footer, with the product's slug added to the request as a tag so a launch email can be scoped to the people who actually wanted that one item.

Mistakes and how they show up

MistakeSymptomFix
A CAPTCHA on a one-field formAdds a render-blocking third-party script for a form a honeypot handles for freeA hidden field with tabIndex={-1} and autoComplete="off", checked server-side
Returning a specific error to the visitorLeaks whether an API key is missing or a service is downLog the real cause server-side; return one generic message to the client
Firing the analytics event inside the Server ActionDouble-fires under Strict Mode re-invocation or any client-side retryFire it from a useEffect gated on the result object's identity, client-side
Copying Resend's audience-scoped endpoint from older docsWorks today, breaks when the deprecated path is removedUse the flat /contacts endpoint with audience_id in the body, and watch for its own deprecation
No hidden-field honeypot labelScreen readers announce an empty, purposeless inputGive it a real (visually hidden) <label>, as this form does, so assistive tech isn't confused by an unlabeled field

Frequently asked questions

Does a waitlist form need a CAPTCHA? Not necessarily — a honeypot field catches the class of bot that fills every input on a form, which is most of them, with none of a CAPTCHA's render cost or accessibility friction. Reach for a CAPTCHA only after a honeypot proves insufficient against your actual traffic.

What happens if RESEND_API_KEY isn't set in production? The action returns { ok: false, message: "Something went wrong..." } and logs "waitlist: RESEND_API_KEY / RESEND_AUDIENCE_ID not configured" server-side — the form fails visibly to the visitor rather than silently swallowing submissions, but without exposing which credential is missing.

Should the success message mention a launch date? Only if you're confident enough in it to be quoted later — this implementation's message ("We'll email you at launch") deliberately makes no date promise, since a missed date is worse for trust than a vague one.

Is a hosted tool (LaunchList, Waitlister) ever the better choice over building this? Yes, if you don't already have an ESP integration and don't need the email to land inside your existing contact list — a hosted embed ships faster. This is worth building yourself specifically when, as here, the email needs to land in the same Resend Audience your other lifecycle email already uses, not a separate silo.

Templates in this post

ASoc Amplify is a social-media-management SaaS site with a results-stats band and a journal — the shape of product a genuine pre-launch waitlist page usually gates. ASoc Atelier is a design-studio portfolio with a five-step process timeline and a booking CTA, a funnel that ends in a form the same way this post's waitlist action does. ASoc Axiom is a dark-theme AI-consultancy site with project-based pricing and a four-question FAQ.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the mechanics this post assumes and doesn't re-cover, the contact-form Server Actions post; for the typed conversion-event union waitlist_joined belongs to, Vercel Analytics vs. Google Analytics. For another landing-page shape built on the same capture mechanics — an episode registry, an RSS feed and a player you don't host — podcast landing pages.

Keep reading

Tutorial9 min read

Web Accessibility Tools: Which Ones Caught This Site's Real Defects

Accessibility 100 on all 8 pages, and two real defects no scanner flagged. Four defects sorted by which tool found them — and why two were structurally invisible.

Read more
Tutorial10 min read

Is Webflow Good for Ecommerce? What the SKU Cap Actually Costs

Webflow's ecommerce plans cap items and charge a transaction fee on top. This storefront's entire commerce stack — entitlements, checkout, webhook — is 843 lines with no cap at all.

Read more
Tutorial8 min read

What Is Supabase Used For? Four Things, in One Real App

Four things, precisely: auth, row-level authorization, private storage, atomic writes -- including the rate-limit race this app actually hit and fixed.

Read more