Referral Waitlist Queue Position in Next.js (Prisma)

A plain “enter your email” box collects addresses it does not give anyone a reason to share. A referral waitlist gives each signup a queue position, a personal link, and a visible reward when friends join. That loop is how pre-launch products turn one visitor into many without paid ads.
This guide shows how to implement a Next.js waitlist with referral queue position in Prisma using the App Router: capture ?ref= on the landing page, persist signups with unique referral codes, compute effective position with boost rules, and redirect users to a status page they can share.
What you are building
End-to-end flow:
Visitor lands with optional
?ref=CODEfrom a friend’s link.They submit email (and consent) to your signup API route.
Prisma creates a row with a unique
referralCode, optional attribution to the referrer, and a base position (join order).You show effective position = base minus boosts from verified referrals (never below 1).
The status page displays position plus
https://yoursite.com/?ref=THEIR_CODEto copy.
That is the standard pattern for a waitlist referral queue position system in Next.js not a global re-sort of thousands of rows on every page view.
Prerequisites
Next.js 14+ with the App Router (Next.js 16 applies the same patterns).
Prisma with PostgreSQL (or any DB Prisma supports).
Basic familiarity with Route Handlers or Server Actions.
Step 1: Model the waitlist in Prisma
Store what you need for referrals and queue math not a full CRM.
model WaitlistEntry {
id String @id @default(cuid())
email String @unique
referralCode String @unique
referredById String?
basePosition Int
confirmedAt DateTime?
createdAt DateTime @default(now())
referredBy WaitlistEntry? @relation("Referrals", fields: [referredById], references: [id])
referrals WaitlistEntry[] @relation("Referrals")
}
Fields that matter:
email— unique; handle duplicate signup gracefully (send existing user to their status page).referralCode— short unique code for?ref=(generate withcrypto.randomBytesor nanoid).referredById— optional FK to the referrer’s row (set only whenrefis valid).basePosition— set once at signup (total count + 1or next sequence); never mutate for boosts.confirmedAt— if you use double opt-in, only count referrals where the new signup confirmed (anti-gaming). See double opt-in with Resend when that post is live.
Run prisma migrate dev after editing the schema.
Step 2: Queue position without re-sorting the whole table
Sorting every signup on each request does not scale. Use stored base position plus boost on read:
const POSITION_BOOST_PER_REFERRAL = 1; // match your product rules
async function countVerifiedReferrals(entryId: string): Promise<number> {
return db.waitlistEntry.count({
where: {
referredById: entryId,
confirmedAt: { not: null }, // skip if double opt-in disabled — use `{}` or remove filter
},
});
}
async function getEffectivePosition(entry: {
id: string;
basePosition: number;
}): Promise<number> {
const verifiedReferrals = await countVerifiedReferrals(entry.id);
const boost = verifiedReferrals * POSITION_BOOST_PER_REFERRAL;
return Math.max(1, entry.basePosition - boost);
}
Why this works: join order stays auditable in basePosition; users see a deterministic effective position that improves when referrals convert. You avoid UPDATE storms that shift everyone’s rank.
If you skip email confirmation in v1, count all referrals with referredById = entry.id instead—know that throwaway emails become easier to abuse; pair with honeypot and rate limiting (upcoming in this series).
Step 3: Generate referral codes and base position
Helper used inside signup:
import { randomBytes } from "crypto";
import { db } from "@/lib/db";
function generateReferralCode(): string {
return randomBytes(4).toString("hex"); // 8 chars; increase length if collision risk grows
}
async function nextBasePosition(): Promise<number> {
const count = await db.waitlistEntry.count();
return count + 1;
}
At signup, assign referralCode and basePosition in the same transaction as referrer attribution.
Step 4: Signup Route Handler (App Router)
Create app/api/waitlist/signup/route.ts:
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { generateReferralCode, nextBasePosition } from "@/lib/waitlist";
export async function POST(request: Request) {
const body = await request.json();
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
const ref = typeof body.ref === "string" ? body.ref.trim() : null;
const consent = body.consent === true;
if (!email || !consent) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
// TODO: honeypot field, rate limit by IP — see abuse-protection post in this series
const existing = await db.waitlistEntry.findUnique({ where: { email } });
if (existing) {
return NextResponse.json({
referralCode: existing.referralCode,
existing: true,
});
}
let referredById: string | undefined;
if (ref) {
const referrer = await db.waitlistEntry.findUnique({
where: { referralCode: ref },
});
if (referrer) referredById = referrer.id;
}
const referralCode = generateReferralCode();
const basePosition = await nextBasePosition();
const entry = await db.$transaction(async (tx) => {
return tx.waitlistEntry.create({
data: {
email,
referralCode,
referredById,
basePosition,
// confirmedAt: null until double opt-in link clicked
},
});
});
// TODO: send welcome or confirmation email via Resend
return NextResponse.json({
referralCode: entry.referralCode,
existing: false,
});
}
Transaction note: wrapping referrer lookup + create prevents partial state if insert fails after you incremented a counter elsewhere. If you only set referredById, the referral count is derived from relations no separate increment to lose.
Step 5: Landing page — capture ?ref=
In app/page.tsx (Server Component), read search params and pass to the client form:
type PageProps = { searchParams: Promise<{ ref?: string }> };
export default async function LandingPage({ searchParams }: PageProps) {
const params = await searchParams;
const refFromUrl = params.ref ?? "";
return (
<main>
{/* hero, countdown, features */}
<WaitlistForm defaultRef={refFromUrl} />
</main>
);
}
Client form posts email, consent, and hidden ref:
"use client";
export function WaitlistForm({ defaultRef }: { defaultRef: string }) {
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
const res = await fetch("/api/waitlist/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: form.get("email"),
consent: form.get("consent") === "on",
ref: form.get("ref") || undefined,
}),
});
const data = await res.json();
if (data.referralCode) {
window.location.href = `/status?code=${encodeURIComponent(data.referralCode)}`;
}
}
return (
<form onSubmit={onSubmit}>
<input type="hidden" name="ref" defaultValue={defaultRef} />
<input type="email" name="email" required />
<label>
<input type="checkbox" name="consent" required /> I agree to receive updates.
</label>
<button type="submit">Join waitlist</button>
</form>
);
}
Using a hidden field avoids re-parsing the URL on submit and keeps Server Actions compatible with the same payload shape.
Step 6: Status page with share link
app/status/page.tsx:
import { db } from "@/lib/db";
import { getEffectivePosition } from "@/lib/waitlist";
import { headers } from "next/headers";
type Props = { searchParams: Promise<{ code?: string }> };
export default async function StatusPage({ searchParams }: Props) {
const { code } = await searchParams;
if (!code) return <p>Missing status code.</p>;
const entry = await db.waitlistEntry.findUnique({
where: { referralCode: code },
});
if (!entry) return <p>Signup not found.</p>;
const position = await getEffectivePosition(entry);
const host = (await headers()).get("x-forwarded-host") ?? "localhost:3000";
const protocol = process.env.NODE_ENV === "production" ? "https" : "http";
const shareUrl = `${protocol}://${host}/?ref=${entry.referralCode}`;
return (
<main>
<h1>You are #{position} in line</h1>
<p>Share your link to move up when friends join:</p>
<code>{shareUrl}</code>
</main>
);
}
For production, prefer NEXT_PUBLIC_APP_URL over parsing headers so share links stay correct behind proxies.
Deep dive on UX and copy for this page: waitlist status page with referral share links (companion post in this series).
Step 7: Referral attribution rules (production)
Validate
refserver-side — never trust a client-only referrer id.Ignore self-referral — if email matches referrer (or same browser session), drop attribution.
Verified referrals only — when double opt-in is on, set
confirmedAton confirm route before referrals affect position.Stable share URLs — always use
/?ref=referralCode, not internal ids.
Schema details for referralCode / referredBy: Prisma waitlist model post.
Step 8: Test checklist
Browser A — sign up without
ref; note position #1 and share URL.Browser B (incognito) — open A’s
?ref=link, sign up with a new email; A’s effective position should improve after B confirms (if using opt-in, after B clicks confirm).Duplicate email — submit same address twice; user should land on existing status, not a 500 from unique constraint.
Invalid ref — signup still succeeds;
referredByIdstays null.Load — spot-check effective position for entry #50 with 0 vs 3 referrals.
Common failures
Position never improves after referrals
Referrals not linked (
referredByIdnull) bad or missingrefon signup.Counting unconfirmed signups while you only confirm in email flow or the opposite.
Mutating
basePositioninstead of using boost-on-read.
Duplicate referral codes
Rare with 8 hex chars at small scale; switch to longer codes or retry on unique violation.
Share link shows wrong domain
Hardcode
NEXT_PUBLIC_APP_URLfor share strings in production.
Ship the full loop without weeks of glue code
Landing, countdown, referral boosts, status page, admin CMS, Resend emails, webhooks, and embed form are the same plumbing every pre-launch product rebuilds.
Launch Waitlist Kit from Skryptix includes:
Dark landing page with join form, consent, honeypot, and rate limiting
Referral links, queue boost rules, and personal status page
Optional double opt-in, admin signups + CSV export,
/embedrouteDocs for Vercel, Docker Postgres, and env vars
FAQ
How do I implement queue position for a referral waitlist in Next.js?
Store a basePosition when each user signs up, count their verified referrals, and compute effectivePosition = max(1, basePosition - referrals × boost) when rendering the status page. Use Prisma for signups and referral attribution via ?ref= on the landing URL.
Should I store rank in the database or calculate it on read?
Store base join order once; apply referral boosts on read (or cache in a column updated when referrals confirm). Avoid re-numbering all rows on every new signup.
How does ?ref= work with the Next.js App Router?
Read searchParams.ref on the landing Server Component, pass it into the signup form as a hidden field, and resolve the referrer by referralCode in your signup API before creating the new row.
Do referral waitlists need double opt-in?
Not strictly, but counting only confirmed emails stops throwaway-address abuse. Launch Waitlist Kit supports optional double opt-in; pair it with verified referral counts.
Can I embed the waitlist on another site?
Yes—serve a minimal join route (for example /embed) in an iframe. See the embed waitlist post in this series (embed-waitlist-form-iframe-nextjs).
Written by the team behind Skryptix. We build Launch Waitlist Kit so founders can focus on the launch—not waitlist infrastructure.
