Every founder needs a waitlist page before launch. The faster you can put up a signup form, collect emails, and start measuring interest, the less time you waste building something nobody wants.
This post shows you how to build one -- a Drizzle ORM schema that tracks signups and referral chains, a position counter, a unique share link for every signup, and a confirmation email via Resend -- wired into a Next.js App Router SaaS.
Two columns do the heavy lifting: referralCode (each user's unique share link) and referredBy (the code that brought them in).
// modules/waitlist/waitlist.schema.ts
import { pgTable, text, timestamp, integer } from 'drizzle-orm/pg-core'
import { createId } from '@paralleldrive/cuid2'
export const waitlistTable = pgTable('waitlist', {
id: text('id').primaryKey().$defaultFn(() => createId()),
email: text('email').notNull().unique(),
referralCode: text('referral_code').notNull().unique().$defaultFn(() => createId()),
referredBy: text('referred_by'),
position: integer('position').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
})
No separate referrals table. Self-referencing on referralCode keeps queries simple and migrations minimal.
The service assigns position (count + 1), inserts the entry, and sends the confirmation email with the share link baked in.
// modules/waitlist/waitlist.service.ts
async function join(email: string, referredBy?: string): Promise<WaitlistEntry> {
const count = await waitlistRepo.count()
const entry = await waitlistRepo.create({ email, referredBy, position: count + 1 })
await emailService.sendEmail({
to: email,
subject: 'You are on the list',
react: React.createElement(WaitlistEmail, {
appName: 'YourApp',
position: entry.position,
referralLink: `${process.env.NEXT_PUBLIC_BASE_URL}/waitlist?ref=${entry.referralCode}`,
}),
})
return entry
}
Position races are unlikely at early launch scale, but if you need strict ordering, wrap the count and insert in a Drizzle transaction.
// app/api/waitlist/route.ts
export async function POST(req: Request) {
const body = await req.json()
const result = joinSchema.safeParse(body)
if (!result.success) return handleError(new HttpError(400, 'Invalid input'))
try {
const entry = await waitlistService.join(result.data.email, result.data.referredBy)
return Response.json(
{ position: entry.position, referralCode: entry.referralCode },
{ status: 201 }
)
} catch (error: unknown) {
return handleError(error)
}
}
Thin route, all logic in the service. The referral code comes back in the response so the UI can display the share link immediately after signup.
A server component reads the ref search param and passes it to the client form.
// app/(main)/waitlist/page.tsx
export default function WaitlistPage({
searchParams,
}: {
searchParams: { ref?: string }
}) {
return <WaitlistForm referredBy={searchParams.ref} />
}
The form stores referredBy in a hidden field. After submission, swap the form for a success state showing the user's position and their personal share link. That link is the flywheel -- every person who signs up through it bumps the original referrer up the list.
If you want to create urgency, show who is sending the most referrals.
// modules/waitlist/waitlist.repo.ts
async function getTopReferrers(limit = 10) {
return db.execute(sql`
SELECT referred_by, COUNT(*) AS referral_count
FROM waitlist
WHERE referred_by IS NOT NULL
GROUP BY referred_by
ORDER BY referral_count DESC
LIMIT ${limit}
`)
}
No extra tables, no extra migrations -- just a query on the data you already have.
This is the kind of feature that takes a full day to build from scratch -- schema design, email setup, referral tracking, the whole loop. With Claude Code Boilerplate, the email infrastructure, database client, and API conventions are already in place. You add the schema, write the service, and ship.
Clone the repo, add the waitlist module, and go live before your next competitor does -- start here.