How to Build a Waitlist + Invite-Only Launch System in Next.js
A practical guide to collecting signups, reviewing applicants, sending expiring invite links, and activating accounts in Next.js without turning your launch into a support mess.

Most people think a waitlist is just an email field and a "notify me" button.
That is fine if all you want is a list of emails in a spreadsheet.
But if you're launching a SaaS and you actually want to control access, approve the right early users, send private invites, and onboard them without chaos, then you're not building a form.
You're building a small access system.
And honestly, that is where a lot of launches get messy.
Founders spend days polishing the landing page, then handle access with a patchwork of Airtable rows, manual emails, half-broken links, and DMs saying, "Hey, can you let me in?" It works for ten users. It becomes painful at fifty. By the time you hit a few hundred signups, your launch starts feeling less like momentum and more like admin work.
If I were building this from scratch today in Next.js, I would keep it simple and opinionated:
- collect interest cleanly
- review people from an admin queue
- generate expiring single-use invites
- verify the invite before creating the account
- let approved users get in with as little friction as possible
That is it. No complicated growth-engineering theater. Just a system that keeps your launch under control.
What You're Actually Building
A good waitlist plus invite-only setup has four jobs:
- Capture interest without letting random bots or duplicate users pollute the list.
- Help you decide who gets access instead of forcing you to approve people manually from your inbox.
- Gate entry cleanly with expiring, trackable invite links.
- Convert approved users into real accounts without making them jump through five extra hoops.
That last part matters more than people think.
If someone is excited enough to join your waitlist and you finally approve them, that is the moment you want the least friction. Asking them to dig through email, re-enter data, and wonder whether the invite is still valid is how you lose warm users for absolutely no good reason.
Start by Defining Launch Modes
Before you touch the UI, define the access states of your product.
This is one of those boring decisions that saves you from weird conditionals later.
For this kind of launch, I like three modes:
WAITLIST_ONLY: new users can join the waitlist, but nobody can freely sign upWAITLIST_WITH_INVITES: users can join the waitlist, and admins can approve specific people with invitesOPEN: public signup is available and the waitlist is no longer required
In Movefast, the flow is driven by a small config like this:
export enum PlatformAccessMode {
WAITLIST_ONLY = "WAITLIST_ONLY",
WAITLIST_WITH_INVITES = "WAITLIST_WITH_INVITES",
OPEN = "OPEN",
}
That one decision gives you a much cleaner mental model:
- Should the waitlist form be visible?
- Should the sign-in page stay open?
- Should public signup be blocked?
- Should invited users bypass the waitlist?
If you do not define those rules up front, they end up scattered across components, route handlers, and middleware. That is where "temporary launch logic" quietly turns into permanent technical debt.
1. Capture Waitlist Signups Like a Real Product
The first public endpoint should be painfully straightforward.
When someone submits their email:
- validate the payload
- reject the request if they already have an account
- reject the request if the email is already on the waitlist
- create the waitlist entry
- send a welcome email
That is exactly the kind of logic that belongs in a Route Handler plus service layer.
export const POST = withAuth(AccessLevel.PUBLIC, async ({ req, user }) => {
if (user) {
throw new ApiError(CONFLICT, "You are already signed in.");
}
const body = await validateBody(req, WaitingListSchema);
await waitingListService.addEmail({ email: body.email });
return handleControllerResponse({
payload: null,
message: "Successfully added to waiting list",
statusCode: CREATED,
});
});
The important part is not the code itself. It is the behavior.
You do not want:
- existing users joining the waitlist again
- duplicate emails clogging your list
- silent failures that leave users wondering if the form worked
You also want to store a little more than just the email. In our setup, the waitlist entry tracks:
| Field | Why it matters |
|---|---|
email | your unique user identifier before account creation |
createdAt | useful for sorting, prioritizing, and launch waves |
launchEmailSent | prevents duplicate launch announcements |
launchEmailSentAt | gives you auditability |
reminderEmailSent | helps control follow-up campaigns |
reminderEmailSentAt | keeps the reminder flow boring and trackable |
That is enough for a strong first version.
You do not need lead-scoring nonsense on day one. You need clean data.
2. Give Yourself an Admin Review Queue
This is where the system stops being "just a waitlist" and becomes useful.
Once signups start coming in, you need one place to review them. Not in Gmail. Not in a spreadsheet tab you forget to refresh. In your app.
Your admin queue does not need to be fancy. It just needs to answer a few questions quickly:
- Who joined recently?
- Who has already been invited?
- Who already received a launch email?
- Who should get approved next?
In Movefast, the waitlist and invite actions are kept behind admin-protected routes, which is the right default:
GET /api/admin/waiting-listGET /api/admin/invitesPOST /api/admin/invites
That separation matters because the public-facing flow and the internal approval flow should not bleed into each other.
One mistake I see a lot is founders trying to "just send invites manually for now." That sounds fine until you need to answer:
- Did we already invite this person?
- Is their link expired?
- Did they create an account?
- Who approved them?
At that point, "manual for now" turns into archaeology.
3. Treat Invites Like Credentials, Not Marketing Links
This is probably the biggest mindset shift.
An invite link is not just an email CTA. It is a temporary credential.
That means it should be:
- unique
- expiring
- single-use
- revocable
- tied to a specific email
Your invite record should track at least this:
| Field | Why it matters |
|---|---|
email | ensures the invite is tied to the intended recipient |
token | the actual secret used in the invite link |
expiresAt | prevents old links from floating around forever |
used / usedAt | enforces single-use behavior |
usedBy | gives you a trace back to the created user |
revoked / revokedAt | lets you cancel access cleanly |
invitedBy | useful for audit trails and team workflows |
Here is the nice part: once you model invites this way, the rest of the system gets much easier to reason about.
Creating an invite becomes a predictable flow:
- Check whether the user already exists.
- Check whether there is already an active invite for that email.
- Create a new invite with expiry.
- Email the invite link.
That is basically what our invite service does, and it keeps the surface area small.
4. Verify the Invite Before You Create Anything
This is the step people skip when they are rushing.
Do not let the account-creation route blindly trust whatever token lands in the request body.
The better flow is:
- User lands on
/auth/invite?token=... - Frontend calls a verification endpoint
- If the token is valid, show a clean acceptance screen
- Only then allow account creation
That small verification step gives you much better UX.
If the invite is expired or invalid, the user sees a clear answer immediately. They do not fill out a form only to fail on submit. It also means your invite acceptance page can be specific and reassuring instead of feeling like a generic auth screen.
Our verification endpoint is intentionally simple:
export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get("token");
if (!token) {
return NextResponse.json({ success: false, message: "Token is required" }, { status: 400 });
}
const invite = await inviteService.verifyInviteToken(token);
if (!invite) {
return NextResponse.json({ success: false, message: "Invalid or expired invite" }, { status: 404 });
}
return NextResponse.json({
success: true,
email: invite.email,
expiresAt: invite.expiresAt,
});
}
There is no drama there. And that is exactly what you want.
5. Make Account Activation Feel Frictionless
Once a user has a valid invite, do not make them work too hard.
At that point, your job is conversion.
In our flow, when the user accepts the invite:
- we verify the token
- we create the user if they do not already exist
- we mark the invite as used
- we generate a magic-link token for instant sign-in
That gives you a very clean one-click path into the app.
export async function POST(request: NextRequest) {
const { token } = await request.json();
const invite = await inviteService.verifyInviteToken(token);
if (!invite) {
throw new ApiError(BAD_REQUEST, "Invalid or expired invite");
}
const existingUser = await userService.getUserByEmail(invite.email);
if (existingUser) {
throw new ApiError(CONFLICT, "User already exists. Please sign in instead.");
}
const newUser = await userService.createUser({
email: invite.email,
name: invite.email.split("@")[0],
});
await inviteService.markInviteAsUsedByToken(token, newUser.id);
const loginToken = await magicLinkService.generateMagicLinkToken(invite.email);
return NextResponse.json({ success: true, loginToken });
}
That flow is especially nice for invite-only launches because it feels curated.
The user does not think, "I am registering for some random SaaS."
They think, "I got approved, clicked the link, and I am in."
That emotional difference is small on paper and surprisingly big in practice.
If you support OAuth as well, even better. Give invited users a second path like Google sign-in, but keep the direct acceptance flow available for the fastest path in.
6. Keep the Email System Boring
Your launch emails should feel human.
Your email infrastructure should feel boring.
Those are two different things.
For this kind of system, I would usually send four email types:
- a welcome email when someone joins the waitlist
- an invite email when they are approved
- a launch email when you open up a new wave
- a reminder email for people who have not acted yet
The key is that every one of those should be state-driven, not memory-driven.
In other words:
- do not guess whether someone already got the email
- do not manually filter CSV exports
- do not resend invites by accident
Store the state. Read the state. Send based on the state.
That is why fields like launchEmailSent and reminderEmailSent are more useful than they look.
If you already have email wired up in your stack, keep the templates short and direct. If you are still setting that up, Movefast already includes the underlying pieces for email and authentication, which saves a lot of repetitive setup work.
Common Mistakes That Make Invite-Only Launches Feel Bad
There are a few mistakes that show up over and over.
1. No invite expiry
If your invite URLs never expire, old links will float around forever.
Someone forwards one six weeks later, someone else clicks it, and now you are debugging a launch policy problem you created yourself.
2. No duplicate checks
If existing users can join the waitlist again, your data gets noisy fast.
The same goes for duplicate active invites. There should be one clean source of truth for whether this person is waiting, invited, or already inside.
3. Account creation before invite verification
This creates messy edge cases:
- accounts created from invalid links
- support requests for expired tokens
- confusing failures halfway through signup
Verify first. Create second.
4. Admin workflow living outside the product
If approvals happen in Slack, Gmail, or a spreadsheet, you do not really have a system. You have a ritual.
Rituals do not scale.
5. Too much friction after approval
Do not spend all your energy building exclusivity, then ruin the moment with a clunky signup flow.
When a user gets approved, they should feel momentum, not paperwork.
A Clean Next.js Architecture for This
If you want to keep the codebase tidy, split the system into a few obvious layers:
- Route Handlers for public and admin API endpoints
- Validation for request bodies and query params
- Service layer for waitlist, invite, auth, and email logic
- Repository layer for database access
- Launch config for switching between waitlist-only, invite-only, and open modes
That is roughly how this project is already organized, and it is a good shape for a product that might change launch strategy over time.
A minimal route map looks like this:
| Route | Responsibility |
|---|---|
POST /api/waiting-list | collect public waitlist emails |
GET /api/admin/waiting-list | review waitlist entries |
POST /api/admin/invites | create and send invite links |
GET /api/auth/verify-invite | validate invite tokens before signup |
POST /api/auth/signup-with-invite | create account from a valid invite |
That is enough to launch a private beta without turning your auth layer into spaghetti.
If I Were Building This Today
I would do it in this order:
- Add access modes first.
- Create the waitlist table and the public submission route.
- Build the admin review queue.
- Add expiring invite tokens with single-use tracking.
- Create the invite-verification page.
- Add one-click account activation.
- Automate the emails last.
That order matters.
If you start with polished email templates before you have the access rules right, you are decorating the wrong part of the system.
If you start with full auth complexity before the invite flow is clean, you end up solving the wrong edge cases first.
Build the access logic. Then make it pleasant.
Final Thought
A waitlist is not just a growth tactic.
At its best, it is a way to protect the first user experience.
It gives you room to onboard the right people, catch issues before they spread, and launch in controlled waves instead of throwing the doors open and hoping for the best.
And if you do it well, it should feel exclusive for users and boring for you.
That is the goal.
If you want to skip the repetitive plumbing and start from a codebase that already has the building blocks for auth, email, admin tools, and gated launch flows, Movefast is built for exactly that.

