DEV Community

huangyongshan46-a11y
huangyongshan46-a11y

Posted on

How to Build a SaaS Waitlist with Email Capture in Next.js 16 (From Scratch)

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)
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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&apos;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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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"',
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Prisma modelWaitlistEntry with email uniqueness
  2. Server action — validates input, saves to DB, sends confirmation email via Resend
  3. useActionState — clean progressive-enhancement form
  4. Admin route — protected view of all signups
  5. 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)