Building a waitlist is one of the first things every SaaS founder needs — before the product is done, before the landing page is polished, before anything. You need a way to capture early interest.
In this tutorial we'll build a complete waitlist with:
- A server action that captures email addresses
- Prisma + PostgreSQL to persist signups
- Resend to send a confirmation email
- A minimal admin view to see who signed up
All with Next.js 16 and zero third-party waitlist services.
1. The Prisma Schema
Add a WaitlistEntry model to your schema.prisma:
model WaitlistEntry {
id String @id @default(cuid())
email String @unique
name String?
source String? // utm_source or referrer
createdAt DateTime @default(now())
confirmed Boolean @default(false)
}
Run npx prisma migrate dev --name add_waitlist.
2. The Server Action
Create app/actions/waitlist.ts:
'use server'
import { prisma } from '@/lib/prisma'
import { Resend } from 'resend'
import { z } from 'zod'
const resend = new Resend(process.env.RESEND_API_KEY)
const schema = z.object({
email: z.string().email(),
name: z.string().optional(),
source: z.string().optional(),
})
export async function joinWaitlist(formData: FormData) {
const result = schema.safeParse({
email: formData.get('email'),
name: formData.get('name'),
source: formData.get('source'),
})
if (!result.success) {
return { error: 'Invalid email address.' }
}
const { email, name, source } = result.data
try {
await prisma.waitlistEntry.create({
data: { email, name, source },
})
} catch (e: any) {
if (e.code === 'P2002') {
// Already on the list — not an error worth surfacing
return { success: true }
}
throw e
}
// Send confirmation email
await resend.emails.send({
from: 'noreply@yourdomain.com',
to: email,
subject: "You're on the list! 🎉",
html: `
<h2>You're in!</h2>
<p>Hey ${name ?? 'there'}, thanks for joining the waitlist.</p>
<p>We'll email you the moment we launch. Stay tuned.</p>
`,
})
return { success: true }
}
3. The Waitlist Form Component
// app/components/WaitlistForm.tsx
'use client'
import { useActionState } from 'react'
import { joinWaitlist } from '@/app/actions/waitlist'
export function WaitlistForm() {
const [state, action, pending] = useActionState(joinWaitlist, null)
if (state?.success) {
return (
<div className="rounded-lg border border-green-500/30 bg-green-500/10 p-4 text-center text-green-400">
🎉 You're on the list! Check your inbox.
</div>
)
}
return (
<form action={action} className="flex flex-col gap-3 sm:flex-row">
<input type="hidden" name="source" value="landing" />
<input
type="text"
name="name"
placeholder="Your name (optional)"
className="rounded-md border border-zinc-700 bg-zinc-900 px-4 py-2 text-white placeholder-zinc-500 focus:border-indigo-500 focus:outline-none"
/>
<input
type="email"
name="email"
placeholder="your@email.com"
required
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-4 py-2 text-white placeholder-zinc-500 focus:border-indigo-500 focus:outline-none"
/>
<button
type="submit"
disabled={pending}
className="rounded-md bg-indigo-600 px-6 py-2 font-medium text-white hover:bg-indigo-500 disabled:opacity-50"
>
{pending ? 'Joining...' : 'Join Waitlist'}
</button>
{state?.error && (
<p className="text-sm text-red-400">{state.error}</p>
)}
</form>
)
}
Drop this into your landing page Hero section and you're live.
4. Admin View — See Who Signed Up
Create a protected admin route at app/admin/waitlist/page.tsx:
import { prisma } from '@/lib/prisma'
import { requireAdmin } from '@/lib/auth'
export default async function WaitlistPage() {
await requireAdmin()
const entries = await prisma.waitlistEntry.findMany({
orderBy: { createdAt: 'desc' },
})
return (
<div className="p-8">
<h1 className="mb-6 text-2xl font-bold">
Waitlist ({entries.length} signups)
</h1>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-zinc-700 text-left text-zinc-400">
<th className="py-2 pr-4">Email</th>
<th className="py-2 pr-4">Name</th>
<th className="py-2 pr-4">Source</th>
<th className="py-2">Joined</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.id} className="border-b border-zinc-800">
<td className="py-2 pr-4 font-mono">{e.email}</td>
<td className="py-2 pr-4">{e.name ?? '—'}</td>
<td className="py-2 pr-4 text-zinc-400">{e.source ?? '—'}</td>
<td className="py-2 text-zinc-400">
{new Date(e.createdAt).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
5. Export Your List as CSV
Add an API route at app/api/admin/waitlist/export/route.ts:
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { requireAdmin } from '@/lib/auth'
export async function GET() {
await requireAdmin()
const entries = await prisma.waitlistEntry.findMany({
orderBy: { createdAt: 'desc' },
})
const csv = [
'email,name,source,joined',
...entries.map(
(e) =>
`${e.email},${e.name ?? ''},${e.source ?? ''},${e.createdAt.toISOString()}`
),
].join('\n')
return new NextResponse(csv, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename="waitlist.csv"',
},
})
}
Hit /api/admin/waitlist/export and you get a clean CSV ready to import into Mailchimp, Loops, or wherever you manage your audience.
The Problem With Building This Yourself
You just spent 30–60 minutes wiring up a waitlist. And honestly, this is the easy part. The same work multiplies when you add:
- Auth (NextAuth / Auth.js v5 setup)
- Stripe billing and webhooks
- Role-based access control
- Team/org support
- Transactional email templates
- Admin dashboard
- API key management
Every one of those is another week of plumbing before you write a single line of your actual product.
LaunchKit ships all of this pre-built — waitlist, auth, Stripe billing, RBAC, teams, admin panel, Resend email, dark mode UI, Prisma schema, the whole stack. One-time $49, production-ready in a day.
If you're building a real SaaS and not a waitlist tutorial, check it out. You'll skip weeks of boilerplate and ship your actual idea instead.
Summary
A production-ready waitlist in Next.js 16:
-
Prisma model —
WaitlistEntrywith email uniqueness - Server action — validates input, saves to DB, sends confirmation email via Resend
-
useActionState— clean progressive-enhancement form - Admin route — protected view of all signups
- CSV export — one-click list download
Ship the waitlist today. Launch the product when it's ready. That's the move.
If this was useful, drop a ❤️ and follow for more Next.js SaaS patterns.
Top comments (0)