Email Marketing15 min read

Waitlist email sequence: 6 SaaS emails from signup to launch day

Six waitlist emails for SaaS founders, from confirmation to launch day: copy, subject lines, Gmail bulk sender rules, an invite batch planner, and tested code.

R

React Emails Pro

October 17, 2026

For a lot of SaaS founders, the first email list arrives before the product does. Someone builds a landing page with Lovable or a Next.js template, adds a waitlist form, and posts it. Signups come in. Then the list sits untouched for two or three months while the product gets built.

Launch day is when it goes wrong. The biggest send your domain has ever made goes to people who signed up in March and can't place your name. Some click unsubscribe, some report spam, and Gmail notices. The waitlist emails you send between signup and launch decide whether that list turns into users or into complaints.

Below are the six emails we'd send, in order, with copy and subject lines you can adapt. After that come the sending limits that bite on launch day (Google, Resend, and Lovable, checked October 17, 2026), a planner for invite batches, and Next.js code we ran against a real database. Building with an AI app builder instead? Skip to the prompts and paste them into your project.

  1. 1
    InstantlyTransactional

    Confirm your spot

    Verify the address with one button. Nothing else.

    Skip or stop if: The address is already confirmed, or a link went out in the last 10 minutes.

  2. 2
    Right after they confirmMarketing

    You're on the list

    Say when invites start, ask one question, and give them a referral link if you use one.

    Skip or stop if: They confirmed an earlier signup and already got this email.

  3. 3
    Every 2 to 4 weeksMarketing

    Build update

    One shipped change, one screenshot, one question. Keeps your name familiar.

    Skip or stop if: They unsubscribed or have already been invited.

  4. 4
    When their batch comes upMarketing

    Your invite is ready

    A link that creates their account with the address you already have.

    Skip or stop if: They already created an account some other way.

  5. 5
    3 days after the inviteMarketing

    One reminder

    A short nudge with the invite deadline and a way to say what stopped them.

    Skip or stop if: They joined, replied, or unsubscribed.

  6. 6
    Public launch dayMarketing

    Doors open

    Tell everyone still waiting that they can sign up now, and what changed since they joined.

    Skip or stop if: They already have an account.

The labels show how we'd treat each email for compliance, which we explain in a moment. The stop rules matter as much as the copy. Each email gets checked against the person's current status just before sending, not when it was scheduled.


A waitlist is a queue you manage

Even famous waitlists let people in gradually. When Robinhood reached the App Store in December 2014, TechCrunch reported 500,000 waitlist signups and a plan to onboard them "within two months", not in a single day. Superhuman had more than 275,000 people waiting in mid-2020, and its founder described the list as a way to control who got in:

“

I think a lot of folks misunderstand the nature of our waitlist. They assume it's some kind of FOMO-generating technique or some kind of false scarcity. Nothing could be further from the truth. The real reason we have the waitlist is that I want everyone who uses Superhuman to be deliriously happy with their experience.

Rahul Vohra

Founder and CEO of Superhuman, speaking to TechCrunch in June 2020

Your list is probably closer to 800 people than 275,000, but the job is the same. Keep each address valid and your name familiar, find out who is waiting, and let people in at a pace your product and your support inbox can handle.

Your launch email is also a deliverability test

For many new products, the invites are the first large send from the domain. Google counts every message your primary domain sends to personal Gmail accounts toward its bulk sender threshold, so your app's sign-in emails and your launch emails share one total. Build updates are your rehearsal: if they get spam reports at 300 recipients, the launch will get more of them at 3,000.


Which waitlist emails count as marketing?

This decides which unsubscribe rules apply and which sending tool you can use. Google's sender FAQ lists "form submission confirmations" as transactional, and that describes email 1 well. Everything after it is promotional in some way, even an invite people asked for. We treat those emails as marketing. Adding an unsubscribe link costs nothing, and arguing the point with a spam filter is not an option.

EmailHow we treat itWhat it needs
1. Confirm your spotTransactionalInstant send, no promotion, plain-text version
2. You're on the listMarketing once it has a referral pushUnsubscribe link and one-click header
3. Build updateMarketingConsent at signup, unsubscribe link, one-click header
4. InviteMarketing, to be safeUnsubscribe link, one-click header, honest deadline
5. ReminderMarketingSame as the invite, sent once
6. Doors openMarketing (a product announcement)Same as the invite

Our reading of Google's sender guidelines. Not legal advice.

In practice, the split changes three things:

  • Gmail requires bulk senders to support one-click unsubscribe on marketing and subscribed messages, using the List-Unsubscribe and List-Unsubscribe-Post headers. Our List-Unsubscribe guide covers the endpoint.
  • Lovable Emails won't send most of the sequence. The Lovable email docs cover auth and app emails, then say "marketing emails, such as newsletters, promotional emails, product announcements, and similar messages are not supported." Lovable's own Resend page sends you to the Resend connector for those.
  • Resend prices the two separately. Transactional plans count emails (the free plan allows 100 a day), while marketing plans count contacts, with up to 1,000 free per month according to the Resend pricing page.

Before anyone signs up, tell them exactly what you'll send. Store the same sentence with each signup, so you can show what someone agreed to if they ask later:

We'll email you to confirm, when your invite is ready, and with a build update about twice a month. Unsubscribe anytime.

Rules differ by country, and this isn't legal advice. In the UK, commentators disagree on whether a waitlist signup counts as "negotiations for a sale" under the ICO's soft opt-in rule. A plain sentence at signup, followed by double opt-in, means you never have to rely on that argument. Our GDPR, CAN-SPAM, and CASL guide covers the rest.


Six waitlist email templates you can adapt

The examples use a made-up analytics product called Northstar and a founder called Mira. Replace anything in brackets with something true about your product. Send from a person's name, because people reply to a person and ignore "The Northstar Team".

1. The confirmation email: one job, one button

Email 1 · sent instantly · transactional

Mira from Northstar <mira@updates.northstar.app>

Confirm your spot on the Northstar waitlist

One click and you're on the list. The link works for 48 hours.

Thanks for joining the Northstar waitlist.

Confirm your email so we can send your invite when it's ready:

Confirm my spot

The link expires in 48 hours. Didn't sign up? Ignore this email and you won't hear from us again.

Mira, founder of Northstar

We recommend double opt-in for waitlists, even though some people never click. A waitlist sits for months and then gets one large send on the day deliverability matters most. A mistyped gmial.com address or a fake address from a bot bounces on launch day instead of on signup day. Bots can also fill your form with real people's addresses, an attack called subscription bombing. Double opt-in means those people get one email they can ignore, and nothing after that.

Leave out the logo banner, the feature list, and the social icons. The email has one job. Put the product name in the subject line so people searching their inbox for your confirmation can find it.

Link scanners click before people do. Corporate security gateways open links in incoming mail to check them. If your confirm link changes the database on a plain GET request, a scanner can confirm an address that a bot submitted. Have the link open a page with a "Confirm my spot" button that submits a form. The code below does this.

2. You're on the list: set expectations, ask one question

Email 2 · right after confirmation

Mira from Northstar <mira@updates.northstar.app>

You're on the list. One question while you wait

Invites go out in small batches from early November.

You're confirmed. Thanks for wanting to try Northstar.

What happens next: we're inviting people in small batches from early November, so we can talk to everyone who gets in. You'll get an email when your invite is ready, plus a short build update about twice a month.

One question: what do you use for reporting today? Hit reply and tell me. I read every answer, and it helps us decide who to invite first.

Know someone who'd want this? Share your link: northstar.app/?ref=k3Xq9aBw. Each friend who confirms moves you up the list.

Mira

You joined the Northstar waitlist on October 17, 2026 at northstar.app. Unsubscribe in one click.

This is the email people are most likely to read, because they clicked a button a few seconds earlier.

Give a date range rather than a queue number. A position like #1,284 is only honest if you invite people in exactly that order. If you invite by fit, by referral count, or by platform, a number that never moves turns into support email. "Invites start in early November" is a promise you can keep.

Then ask one question people can answer in a line. The replies tell you who is waiting and what they use now. Superhuman used a survey the same way, to learn which waitlisted users needed features it hadn't built yet. Only say the answers affect who gets in first if that is true.

Finally, put the signup date and site in the footer, here and in every email after it. Two months later, "You joined on October 17 at northstar.app" is the line that stops someone reaching for the spam button.

3. Build updates: the email that keeps the list alive

Send one every two to four weeks while people wait. Each update needs a real change, something to look at, and one small ask. If you went quiet for a couple of months, start the next update by reminding people who you are and when they joined.

copy/build-update.txt
Subject: Northstar update: scheduled reports work now
Preview: Plus a 40-second demo and one question for you.

Hi [first name, or "there"],

Quick update from Northstar. You joined the waitlist on [date].

Shipped: scheduled reports. Pick a time and the report lands in your inbox.
[Screenshot or short GIF, hosted image, with alt text]

Next: Slack delivery. Invites start in [month].

One ask: would you run scheduled reports daily or weekly?
Reply with one word.

Mira

You're getting this because you joined the Northstar waitlist on [date]
at northstar.app. Unsubscribe in one click: [link]

Skip "Exciting news!" and the five-feature roundup. One change explained well gets more replies than five listed. Replies also give you quotes for the launch page, with permission.

4. The invite: a link that already knows who they are

Email 4 · when their batch comes up

Mira from Northstar <mira@updates.northstar.app>

Your Northstar invite is ready

Your account is set up for this address. It takes about two minutes.

Your spot came up. You can start using Northstar today.

The button creates your account with this address. [Most people connect a data source and see their first report in about two minutes.]

Create my account

[Your invite works until November 21. After that we'll offer the spot to someone else, and you can rejoin the list anytime.]

Questions? Reply to this email.

Mira

You're getting this because you joined the Northstar waitlist on October 17, 2026. Unsubscribe in one click.

The button should skip as much of signup as it can. You already have a confirmed address, so a signed invite link can pre-fill it or sign the person in directly, the way a magic link does. Each extra form field loses people who were interested enough to wait.

Only include a deadline if you will enforce it. "Your invite works until November 21" is fair urgency when the spot does go to the next person. A fake countdown teaches people to ignore your next email. Once someone creates an account, the waitlist sequence ends and your onboarding sequence takes over.

5. One reminder, then stop

Three days after the invite, send one short reminder to anyone who hasn't created an account: "Your invite is still waiting. It works until November 21. If something stopped you, reply and tell me what it was." The last sentence brings in the bug reports and objections that analytics can't show you.

After that, stop sending invite emails. People who don't act on an invite and a reminder can stay on build updates until they unsubscribe. Don't use opens to decide who is still interested. Apple Mail Privacy Protection loads images automatically, so opens overcount. Use clicks and replies.

6. Doors open: the public launch email

copy/doors-open.txt
Subject: Northstar is open to everyone today
Preview: You were early. Here's what changed since you joined.

Hi [first name, or "there"],

Northstar opens to everyone today. You joined the waitlist on [date],
before most of the people reading about us this week.

Since you signed up, we shipped:
- [Change 1]
- [Change 2]
- [Change 3]

[Only if true: Waitlist members get [offer] until [date].]

[Get started]

Thanks for waiting. Reply if something you need is still missing.
Mira

Send this to everyone still waiting, including people whose batch never came up. If you launch on Product Hunt the same day, link to the page, but keep the product as the main call to action.

Waitlist email subject lines

EmailSubject linePreview text
1. ConfirmConfirm your spot on the Northstar waitlistOne click and you're on the list. The link works for 48 hours.
2. On the listYou're on the list. One question while you waitInvites go out in small batches from early November.
3. Build updateNorthstar update: scheduled reports work nowPlus a 40-second demo and one question for you.
4. InviteYour Northstar invite is readyYour account is set up for this address. About two minutes.
5. ReminderYour Northstar invite is still waitingIt works until November 21. Reply if something stopped you.
6. Doors openNorthstar is open to everyone todayYou were early. Here's what changed since you joined.

Every subject names the product, because recognition is what people lose while they wait.


Should your waitlist have a referral queue?

In a referral waitlist, sharing your link moves you up the list. Robinhood's pre-launch page is the example most people cite. It can work, but it also changes who reaches the front of your queue. For a B2B tool with a few hundred signups, we'd skip it and put the effort into build updates.

Why founders add one
  • Gives people a concrete reason to share the page
  • Shows which channels bring confirmed signups, not just clicks
  • The mechanic is familiar, so it needs little explanation
What it costs
  • Rewards people who share, who may not be your best-fit users
  • Attracts fake signups made to game the count
  • Turns positions into a support topic when they jump around
  • Makes an honest timeline harder to give

If you add one, count a referral only when the referred person confirms their address. The code below does this. A referral from an unconfirmed address is worth nothing and is easy to fake. Cap how far one person can jump, and keep the "people ahead of you" number consistent with the order you invite in.


Plan invite batches before launch day

Batches protect your support inbox and your sender reputation. These are the limits that decide their size, all checked on October 17, 2026:

  • Gmail's bulk sender line. Google defines a bulk sender as one that sends "close to 5,000 messages or more to personal Gmail accounts within a 24-hour period." Messages from the same primary domain count together, and the status is permanent once you reach it.
  • Spam rate. Google asks senders to keep the rate in Postmaster Tools below 0.1% and never reach 0.3%. Three spam reports per 1,000 delivered emails gets you to 0.3%.
  • Ramping up. Google's sender guidelines warn: "immediately doubling previously sent volumes suddenly could result in rate limiting or reputation drops."
  • Resend's free plan sends 100 emails per UTC day and 3,000 a month. Paid plans remove the daily cap. Every account starts with a limit of 10 API requests per second, shared by your whole team.
  • Lovable Emails hourly caps. App emails are capped per hour by plan (100 on Pro, 300 on Business). If a launch post brings in 400 signups in an hour on Pro, confirmation emails past the cap are rejected until the hour resets.

Enter your own numbers below. The defaults show a 4,000-person list with a first batch of 100.

Launch batch planner

Count only confirmed addresses

Small enough to read every reply

What support and infra can absorb

Time to fix what batch 1 finds

Count it in your list

Growth per batch

Each batch vs the one before

8

Batches

15

Days to invite all

1,000

Largest batch

  • Your largest batch sends about 400 invites to personal Gmail addresses, under Google's 5,000-a-day bulk sender line. Your app's other emails from the same domain count toward it too.
  • Batches over 100 won't fit Resend's free plan, which caps sending at 100 emails per UTC day.
BatchDayInvitesInvited so far
11100100 (3%)
23150250 (6%)
35225475 (12%)
47338813 (20%)
595061,319 (33%)
6117592,078 (52%)
7131,0003,078 (77%)
8159224,000 (100%)

With the defaults, inviting all 4,000 people takes eight batches over 15 days at 1.5× growth. Doubling each batch finishes in seven batches and 13 days. So the slower ramp costs two days, and in exchange your first three batches reach 475 people instead of 700. Whatever breaks in onboarding breaks for fewer people.

Read the replies from batch 1 before you send batch 2. A broken onboarding step found by 100 people is a quick fix. Found by 4,000, it becomes a pile of support email and a bad first impression. Our domain warm-up guide covers ramping a brand-new domain, and the SPF, DKIM, and DMARC guide covers the authentication Gmail checks first.

Email sender guidelines - Google Workspace Admin Help

Google's requirements for all senders and bulk senders: authentication, spam rate, one-click unsubscribe, and sending volume.

support.google.com

Send branded emails from your own domain - Lovable Docs

Which emails Lovable Emails sends, custom sender domains, monthly allowance, and hourly caps per plan.

docs.lovable.dev


Build the waitlist in Next.js with Resend and Postgres

This code covers the signup form action, the confirm step, the confirmation email, and an invite batch script. The database calls use postgres.js, and the same SQL works on Supabase or Neon.

What we tested

On October 17, 2026 we ran this code against PostgreSQL 17 with react-email 6.11.3, resend 6.34.1, postgres 3.4.9, zod 4.6.7, and next 16.4.2, with Resend's API stubbed out. A second signup within 10 minutes sent nothing. A used confirm link failed, and so did one older than 48 hours. Re-running an invite batch after a failed send sent only to the person who was missed. After a simulated crash, the retry reused the same idempotency key and the batch stayed at its original size.

The table

db/waitlist.sql
create extension if not exists citext;

create table waitlist_batches (
  id         integer primary key,
  size       integer not null check (size > 0),
  created_at timestamptz not null default now()
);

create table waitlist_signups (
  id                 uuid primary key default gen_random_uuid(),
  email              citext not null unique,
  status             text not null default 'pending'
                     check (status in ('pending', 'confirmed', 'invited', 'joined', 'unsubscribed')),
  source             text,
  consent_text       text not null,
  confirm_token_hash text,
  confirm_sent_at    timestamptz,
  confirmed_at       timestamptz,
  referral_code      text unique,
  referred_by        uuid references waitlist_signups (id),
  invite_batch       integer references waitlist_batches (id),
  invited_at         timestamptz,
  joined_at          timestamptz,
  created_at         timestamptz not null default now()
);

-- Supabase: no policies, so only the server connection can read or write.
alter table waitlist_signups enable row level security;
alter table waitlist_batches enable row level security;

consent_text stores the sentence shown next to the button at the moment of signup. When you reword it, new signups get the new version and older rows keep what those people saw. On Supabase, row level security with no policies keeps the browser out entirely; only your server connection can read the list.

Signup and confirm actions

lib/waitlist.ts
import { createHash, createHmac } from "node:crypto";

// Store this exact sentence with each signup. It is your consent record.
export const CONSENT_TEXT =
  "We'll email you to confirm, when your invite is ready, and with a build update about twice a month. Unsubscribe anytime.";

export const APP_URL = process.env.APP_URL ?? "https://northstar.app";
export const FROM = "Mira at Northstar <mira@updates.northstar.app>";

export function sha256(value: string) {
  return createHash("sha256").update(value).digest("hex");
}

export function sign(value: string) {
  const secret = process.env.WAITLIST_SECRET;
  if (!secret) throw new Error("WAITLIST_SECRET is not set");
  return createHmac("sha256", secret).update(value).digest("base64url");
}
app/waitlist/actions.ts
"use server";

import { randomBytes } from "node:crypto";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { Resend } from "resend";
import { z } from "zod";
import { sql } from "@/lib/db";
import { APP_URL, CONSENT_TEXT, FROM, sha256 } from "@/lib/waitlist";
import { WaitlistConfirmEmail } from "@/emails/waitlist-confirm";

const resend = new Resend(process.env.RESEND_API_KEY);

const JoinSchema = z.object({
  email: z.email().max(254),
  source: z.string().max(40).optional(),
  ref: z.string().max(16).optional(),
  turnstileToken: z.string().min(1),
});

export type JoinResult = { ok: true } | { ok: false; error: string };

export async function joinWaitlist(_prev: JoinResult | null, formData: FormData): Promise<JoinResult> {
  const parsed = JoinSchema.safeParse({
    email: formData.get("email"),
    source: formData.get("source") || undefined,
    ref: formData.get("ref") || undefined,
    turnstileToken: formData.get("cf-turnstile-response"),
  });
  if (!parsed.success) return { ok: false, error: "Enter a valid email address." };

  const ip = (await headers()).get("x-forwarded-for")?.split(",")[0]?.trim();
  if (!(await verifyTurnstile(parsed.data.turnstileToken, ip))) {
    return { ok: false, error: "We couldn't check that you're human. Please try again." };
  }

  const email = parsed.data.email.trim().toLowerCase();
  const token = randomBytes(32).toString("base64url");
  const tokenHash = sha256(token);

  // New signup, or a fresh link for a pending one. At most one email per address every 10 minutes.
  const [row] = await sql<{ id: string }[]>`
    insert into waitlist_signups (email, source, consent_text, confirm_token_hash, confirm_sent_at, referred_by)
    values (
      ${email}, ${parsed.data.source ?? null}, ${CONSENT_TEXT}, ${tokenHash}, now(),
      (select id from waitlist_signups where referral_code = ${parsed.data.ref ?? null} and status <> 'pending')
    )
    on conflict (email) do update
      set confirm_token_hash = excluded.confirm_token_hash, confirm_sent_at = now()
      where waitlist_signups.status = 'pending'
        and (waitlist_signups.confirm_sent_at is null
             or waitlist_signups.confirm_sent_at < now() - interval '10 minutes')
    returning id
  `;

  // No row means already confirmed or emailed recently. Same response, so the form can't reveal who signed up.
  if (!row) return { ok: true };

  const { error } = await resend.emails.send(
    {
      from: FROM,
      to: email,
      subject: "Confirm your spot on the Northstar waitlist",
      react: WaitlistConfirmEmail({
        confirmUrl: `${APP_URL}/waitlist/confirm?t=${token}`,
        productName: "Northstar",
        senderName: "Mira, founder of Northstar",
      }),
    },
    { idempotencyKey: `waitlist-confirm/${row.id}/${tokenHash.slice(0, 16)}` },
  );

  if (error) {
    // Clear the cooldown so the person can retry straight away.
    await sql`update waitlist_signups set confirm_sent_at = null where id = ${row.id}`;
    console.error("waitlist confirm email failed", { id: row.id, error });
    return { ok: false, error: "We couldn't send your confirmation email. Please try again in a minute." };
  }

  return { ok: true };
}

// Called from a button on the confirm page, not on GET, so link scanners can't confirm for the user.
export async function confirmWaitlist(formData: FormData) {
  const token = String(formData.get("token") ?? "");
  const [row] = await sql<{ referral_code: string }[]>`
    update waitlist_signups
       set status = 'confirmed', confirmed_at = now(), confirm_token_hash = null,
           referral_code = ${randomBytes(6).toString("base64url")}
     where confirm_token_hash = ${sha256(token)}
       and status = 'pending'
       and confirm_sent_at > now() - interval '48 hours'
    returning referral_code
  `;
  if (!row) redirect("/waitlist/expired");

  // Queue the "You're on the list" email (email 2) here.
  redirect(`/waitlist/confirmed?ref=${row.referral_code}`);
}

async function verifyTurnstile(token: string, ip?: string): Promise<boolean> {
  const secret = process.env.TURNSTILE_SECRET_KEY;
  if (!secret) throw new Error("TURNSTILE_SECRET_KEY is not set");

  const body = new URLSearchParams({ secret, response: token });
  if (ip) body.set("remoteip", ip);

  const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
  if (!res.ok) return false;
  const data = (await res.json()) as { success: boolean };
  return data.success;
}

Why the file looks the way it does:

  • The form answers the same way whether the address is new, pending, or already confirmed, so nobody can use it to check who signed up.
  • The 10-minute cooldown lives in the on conflict clause. A bot that submits one person's address 500 times sends them one email, and Cloudflare Turnstile stops most of those submissions before they reach the database anyway.
  • Only a hash of the token is stored, so a leaked database backup can't be used to confirm addresses.
  • Confirming is a form action. The email link opens a page, and the button on that page confirms the address:
app/waitlist/confirm/page.tsx
import { confirmWaitlist } from "../actions";

export default async function ConfirmPage({ searchParams }: { searchParams: Promise<{ t?: string }> }) {
  const { t = "" } = await searchParams;
  return (
    <form action={confirmWaitlist}>
      <input type="hidden" name="token" value={t} />
      <button type="submit">Confirm my spot</button>
    </form>
  );
}

The confirmation email

emails/waitlist-confirm.tsx
import {
  Body, Button, Container, Head, Html, Preview, Tailwind, Text, pixelBasedPreset,
} from "react-email";

export type WaitlistConfirmEmailProps = {
  confirmUrl: string;
  productName: string;
  senderName: string;
};

export function WaitlistConfirmEmail({ confirmUrl, productName, senderName }: WaitlistConfirmEmailProps) {
  return (
    <Html lang="en">
      <Tailwind config={{ presets: [pixelBasedPreset] }}>
        <Head />
        <Body className="bg-white font-sans text-gray-900">
          <Preview>One click and you&apos;re on the list. The link works for 48 hours.</Preview>
          <Container className="mx-auto max-w-[520px] px-6 py-8">
            <Text className="text-base leading-6">Thanks for joining the {productName} waitlist.</Text>
            <Text className="text-base leading-6">
              Confirm your email so we can send your invite when it&apos;s ready:
            </Text>
            <Button
              href={confirmUrl}
              className="box-border rounded-md bg-gray-900 px-5 py-3 text-base font-semibold text-white no-underline"
            >
              Confirm my spot
            </Button>
            <Text className="text-sm leading-5 text-gray-600">
              The link expires in 48 hours. Didn&apos;t sign up? Ignore this email and you won&apos;t hear from us again.
            </Text>
            <Text className="text-sm leading-5 text-gray-600">{senderName}</Text>
          </Container>
        </Body>
      </Tailwind>
    </Html>
  );
}

WaitlistConfirmEmail.PreviewProps = {
  confirmUrl: "https://northstar.app/waitlist/confirm?t=preview",
  productName: "Northstar",
  senderName: "Mira, founder of Northstar",
} satisfies WaitlistConfirmEmailProps;

export default WaitlistConfirmEmail;

Rendered, this email is 4,705 bytes of HTML, far below the 102 KB point where Gmail clips messages. Resend generates the plain-text version automatically when you pass a React component. For email 2, the AI SaaS pack includes a Waitlist Confirmed layout with a spot number, an expected invite window, and a referral link, if you would rather adapt a finished design.

The invite batch script

scripts/send-invite-batch.ts
import { setTimeout as sleep } from "node:timers/promises";
import { Resend } from "resend";
import { sql } from "../lib/db";
import { APP_URL, FROM, sign } from "../lib/waitlist";
import { WaitlistInviteEmail } from "../emails/waitlist-invite";

const resend = new Resend(process.env.RESEND_API_KEY);

// Deterministic links: a retry sends the exact same payload, so the idempotency key still matches.
const joinUrl = (id: string, batch: number) => `${APP_URL}/join?i=${id}&b=${batch}&s=${sign(`join.${id}.${batch}`)}`;
const unsubscribeUrl = (id: string) => `${APP_URL}/api/unsubscribe?i=${id}&s=${sign(`unsub.${id}`)}`;

async function main() {
  const batch = Number(process.argv[2]);
  const size = Number(process.argv[3]);
  if (!Number.isInteger(batch) || !Number.isInteger(size) || size < 1) {
    throw new Error("Usage: npx tsx scripts/send-invite-batch.ts <batch-number> <size>");
  }

  // Pick the batch once. Re-running the same batch number never adds more people.
  await sql.begin(async (tx) => {
    const created = await tx`
      insert into waitlist_batches (id, size) values (${batch}, ${size})
      on conflict (id) do nothing
      returning id
    `;
    if (created.length === 0) return;
    await tx`
      update waitlist_signups set invite_batch = ${batch}
      where id in (
        select w.id from waitlist_signups w
        where w.status = 'confirmed' and w.invite_batch is null
        order by (select count(*) from waitlist_signups r
                  where r.referred_by = w.id and r.status <> 'pending') desc,
                 w.confirmed_at
        limit ${size}
      )
    `;
  });

  const invitees = await sql<{ id: string; email: string }[]>`
    select id, email from waitlist_signups
    where invite_batch = ${batch} and status = 'confirmed'
    order by confirmed_at
  `;

  let sent = 0;
  let failed = 0;
  for (const person of invitees) {
    const { error } = await resend.emails.send(
      {
        from: FROM,
        to: person.email,
        subject: "Your Northstar invite is ready",
        react: WaitlistInviteEmail({ joinUrl: joinUrl(person.id, batch) }),
        headers: {
          "List-Unsubscribe": `<${unsubscribeUrl(person.id)}>`,
          "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
        },
        tags: [{ name: "campaign", value: `invite-batch-${batch}` }],
      },
      { idempotencyKey: `waitlist-invite/${batch}/${person.id}` },
    );

    if (error) {
      failed++;
      console.error("invite failed", { id: person.id, error });
    } else {
      sent++;
      await sql`update waitlist_signups set status = 'invited', invited_at = now() where id = ${person.id}`;
    }
    // About 8 requests a second, under Resend's default team limit of 10 shared with your app.
    await sleep(125);
  }

  console.log(`Batch ${batch}: ${sent} sent, ${failed} failed. Re-run the same command to retry failures.`);
  await sql.end();
}

main().catch(async (err) => {
  console.error(err);
  await sql.end();
  process.exit(1);
});

Run it with npx tsx scripts/send-invite-batch.ts 1 100, then 2 150 two days later, using the sizes from your plan. Three details keep it safe to re-run:

  • The waitlist_batches row is created once, so running batch 1 again sends to the same people instead of picking 100 more.
  • Invite links are signed from the person's ID and batch number instead of generated at random. A retry builds the exact same email, so Resend's idempotency key (kept for 24 hours) returns the original result instead of sending twice. Resend rejects a reused key with a different payload, which is the same problem we covered in Stripe duplicate emails.
  • It sends about eight requests a second. Resend's 10-per-second limit is shared with your app, so a full-speed batch could rate-limit your own password reset emails. Resend's batch endpoint (up to 100 emails per call) is faster for large lists, but then one idempotency key covers the whole chunk.

Building in Lovable, Bolt, or Cursor? Paste these prompts

You don't need to write the code above yourself. These prompts describe the same behavior for an AI app builder. In Lovable, the confirmation email can go through Lovable Emails as an app email (paid plans), while build updates and launch emails need the Resend connector, because Lovable Emails doesn't send marketing email. Either way, verify your own sending domain first. Resend's test address only delivers to the account owner.

Waitlist signup with double opt-in

Lovable · Bolt · v0 · Cursor · Claude Code

Add a waitlist to this app. Check the existing code first and tell me what you will change before you change it.

1. Create a private waitlist_signups table: email (unique, case-insensitive), status (pending, confirmed, invited, joined, unsubscribed), source, consent_text, a hashed confirm token, timestamps, referral_code, and referred_by. The browser must never read this table directly.
2. Under the signup form, show exactly this sentence and save it with each signup: "We'll email you to confirm, when your invite is ready, and with a build update about twice a month. Unsubscribe anytime."
3. On submit, run server-side: validate the email, check a Cloudflare Turnstile token, and send at most one confirmation email per address every 10 minutes. Always show the same success message, whether or not the address already exists.
4. The confirmation email has one button. The link expires after 48 hours and opens a page with a "Confirm my spot" button. Only that button press confirms the address, never just opening the link.
5. After confirming, show a thank-you page with a referral link. Count a referral only after the referred person confirms.
6. Keep all API keys in server-side secrets. Use my verified sending domain, not a test address.

When you're done, list every file you changed and how I can test it with my own email address.

Invite batches with safe retries

Lovable · Bolt · v0 · Cursor · Claude Code

Add an admin-only action that invites the next batch from the waitlist.

- Input: a batch number and a batch size. Running the same batch number twice must not invite extra people.
- Order: most confirmed referrals first, then earliest confirmation.
- Each invite link must be signed and deterministic (built from the signup ID and batch number), so a retry sends the identical email.
- Send each email with an idempotency key like waitlist-invite/{batch}/{signupId}, plus List-Unsubscribe and List-Unsubscribe-Post headers pointing to a one-click unsubscribe endpoint.
- Mark a person as invited only after the send succeeds. Report how many sent and failed, and let me re-run the same batch to retry failures.
- Send no more than 8 emails per second.

Don't send anything for real until I confirm. Show me the batch it would send first.

Before you trust the flow, sign up with your own Gmail and Outlook addresses. Check that the confirm link points to your live domain and not localhost. If nothing arrives, our guide to vibe-coded apps that don't send email walks through the usual causes.


Five numbers to watch while people wait

Benchmarks from other products won't tell you much about your list. Measure these from your first batch, then watch how they move.

MetricHow to calculate itWhat it tells you
Confirmation rateConfirmed ÷ signups, per traffic sourceA source with a very low rate is sending bots or low-intent traffic
Reply rate on email 2Replies ÷ emails deliveredWhether people care enough to tell you something
Unsubscribes per updateUnsubscribes ÷ updates deliveredRising numbers mean the updates are too frequent or too thin
Gmail spam rateGoogle Postmaster Tools, dailyKeep it under 0.1% before you send a large batch
Invite-to-account rateAccounts created ÷ invites sent, per batchWhether the invite and signup flow work. Fix it before the next batch

Postmaster Tools only shows data once you send enough Gmail traffic, so a small list may see nothing at first. Until then, your provider's complaint webhooks are the early warning. Our complaint monitoring guide shows how to wire them up.


Waitlist email FAQ

What should a waitlist confirmation email say?

Say what the person signed up for, give them one button to confirm their address, say when the link expires, and tell anyone who didn't sign up that they can ignore it. Leave out feature lists, banners, and social links. The only job is the click.

How often should I email my waitlist?

Send a build update every two to four weeks while people wait. With longer gaps, more people forget who you are, and that shows up as unsubscribes and spam reports when you send invites. Each update needs one real change and one question.

Should I show people their waitlist position?

Only if you invite people in that order. A position that jumps around, or a number that never moves, creates support email and distrust. If you invite by fit or by referral count, give a time estimate such as 'invites start in early November' instead.

Is a waitlist email transactional or marketing?

The email that confirms the address works like a form submission confirmation, which Google treats as transactional. Build updates, invites, and launch announcements are safer to treat as marketing, with an unsubscribe link and a one-click List-Unsubscribe header.

How many waitlist invites should I send at once?

Start with a batch small enough that you can read every reply, often 50 to 200 people. Grow each batch by about 1.5 times while complaints stay low. Keep the largest day below what your support inbox can handle and below Gmail's bulk sender line of close to 5,000 messages to personal Gmail accounts in 24 hours, unless you already meet the bulk sender rules.

Can Lovable send waitlist emails?

On paid plans, Lovable Emails can send the confirmation as an app email, within hourly caps per plan. Its docs say marketing emails such as newsletters and product announcements are not supported, so send build updates and launch emails through the Resend connector or another marketing tool. Docs checked October 17, 2026.

Does double opt-in hurt waitlist growth?

You lose the signups who never click. Most of those addresses were unlikely to become users, and typos or fake addresses would bounce on launch day, when deliverability matters most. A smaller confirmed list beats a larger unconfirmed one.

Key takeaway

A waitlist turns into users when people remember you and get in at a pace your product can handle. Before you share the signup page:

  • Put one sentence about what you'll send next to the button, and store it with each signup.
  • Use double opt-in with a 48-hour link that opens a confirm page with a button.
  • Send email 2 right after confirmation, with a timeline and one question.
  • Send a build update every two to four weeks that says when they signed up.
  • Invite in batches that grow about 1.5× and read batch 1's replies before batch 2.
  • Add an unsubscribe link and one-click header to every email after the confirmation.
  • Make invite links deterministic so a retry can't send twice.
R

React Emails Pro

Team

Building production-ready email templates with React Email. Writing about transactional email best practices, deliverability, and developer tooling.

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates