Referral Waitlist Queue Position in Next.js (Prisma)

October 13, 2026
Referral Waitlist Queue Position in Next.js (Prisma)
Launch Waitlist Kit Launch Waitlist Kit Production-ready Next.js 16 waitlist launch kit: dark landing page, referral queue boosts, status page, admin CMS (branding, FAQ, legal, signups), Resend emails, webhooks, embed form, CSV export, and full setup docs. $24 How to Build a Referral Waitlist with Queue Position in Next.js (Prisma + App Router)

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:

  1. Visitor lands with optional ?ref=CODE from a friend’s link.

  2. They submit email (and consent) to your signup API route.

  3. Prisma creates a row with a unique referralCode, optional attribution to the referrer, and a base position (join order).

  4. You show effective position = base minus boosts from verified referrals (never below 1).

  5. The status page displays position plus https://yoursite.com/?ref=THEIR_CODE to 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 with crypto.randomBytes or nanoid).

  • referredById — optional FK to the referrer’s row (set only when ref is valid).

  • basePosition — set once at signup (total count + 1 or 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)

  1. Validate ref server-side — never trust a client-only referrer id.

  2. Ignore self-referral — if email matches referrer (or same browser session), drop attribution.

  3. Verified referrals only — when double opt-in is on, set confirmedAt on confirm route before referrals affect position.

  4. Stable share URLs — always use /?ref=referralCode, not internal ids.

Schema details for referralCode / referredBy: Prisma waitlist model post.


Step 8: Test checklist

  1. Browser A — sign up without ref; note position #1 and share URL.

  2. 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).

  3. Duplicate email — submit same address twice; user should land on existing status, not a 500 from unique constraint.

  4. Invalid ref — signup still succeeds; referredById stays null.

  5. Load — spot-check effective position for entry #50 with 0 vs 3 referrals.


Common failures

Position never improves after referrals

  • Referrals not linked (referredById null) bad or missing ref on signup.

  • Counting unconfirmed signups while you only confirm in email flow or the opposite.

  • Mutating basePosition instead 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_URL for share strings in production.


Ship the full loop without weeks of glue code

Launch Waitlist Kit Launch Waitlist Kit Production-ready Next.js 16 waitlist launch kit: dark landing page, referral queue boosts, status page, admin CMS (branding, FAQ, legal, signups), Resend emails, webhooks, embed form, CSV export, and full setup docs. $24

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, /embed route

  • Docs for Vercel, Docker Postgres, and env vars

Get Launch Waitlist Kit →


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.