guide·starter·updated 2026-04-09·~2 hours, honestly

Ship a waitlist in 2 hours

Single page. Email capture. Double opt-in. Referral counter. A real admin view. All in the time it takes to watch one Champions League match.

Next.jsResendSupabase
you'll need
  • A laptop with Node.js 20+
  • A free Supabase account (10 min to create)
  • A Resend account with one verified sending domain (DNS takes 5–30 min)
  • A Vercel account for deploy
  • No prior Next.js experience, but some React comfort helps
you'll end up with

A deployed waitlist at your-domain.com with working email confirmation, a referral counter per subscriber, and an admin page at /admin showing signups, sources, and CSV export.

This guide skips the theory. Every block below is something you actually type, run, or paste. If a step is longer than one action, it gets its own heading. If it's not essential to shipping, it's not here. If you hit an error we didn't cover, open a discussion — we'll add it.

§0. Scope check

Before you start, make sure this is actually what you want. A waitlist is correct when:

  • You want to gauge interest before building the real thing
  • You're running a paid ad and need a landing page that collects emails
  • You're a month out from launch and want to warm a list

A waitlist is wrong when: you already have a product that works, you already have signups, or you're using it to delay a real launch. In those cases, ship the product.

§1. Scaffold + deps

Fresh Next.js 16 project with the App Router. The --turbopack flag is the default in 16, but it doesn't hurt to be explicit.

npx create-next-app@latest my-waitlist --typescript --tailwind --app --no-src-dir
cd my-waitlist
npm install @supabase/supabase-js resend nanoid

Three dependencies. Supabase is our database client, Resend sends the confirmation email, nanoid generates URL-safe referral codes. That's it. No auth library, no form library, no state manager — you don't need any of them.

§2. The database

Create a Supabase project at supabase.com. Free tier is plenty. Once it's ready, grab the project URL and the service role key (Settings → API). Put them in your .env.local.

.env.local
dotenv
NEXT_PUBLIC_APP_URL=http://localhost:3000

SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_KEY=eyJhbGciOi...

RESEND_API_KEY=re_...
RESEND_FROM_EMAIL="Your Product <hello@yourdomain.com>"

ADMIN_PASSWORD=replace-me-before-deploy

Now open the Supabase SQL editor and run this once. It creates the table with the fields we need plus a unique index on email so duplicate signups don't spawn duplicate rows.

supabase / sql editor
sql
create table if not exists subscribers (
  id uuid primary key default gen_random_uuid(),
  email text not null,
  referral_code text not null unique,
  referred_by text,
  source text,
  confirmed boolean not null default false,
  created_at timestamptz not null default now()
);

create unique index if not exists subscribers_email_unique
  on subscribers (lower(email));

§3. Signup API route

Create a single POST route that: validates the email, generates a referral code, inserts (or upserts) the row, and kicks off a confirmation email. We'll put the Supabase client in a tiny lib file so any future route can import it.

lib/supabase.ts
ts
import { createClient } from "@supabase/supabase-js";

export const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_KEY!,
  { auth: { persistSession: false } },
);
app/api/subscribe/route.ts
ts
import { NextResponse } from "next/server";
import { nanoid } from "nanoid";
import { supabase } from "@/lib/supabase";
import { sendConfirmationEmail } from "@/lib/email";

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export async function POST(req: Request) {
  const { email, referredBy, source } = await req.json();

  if (!email || !EMAIL_RE.test(email)) {
    return NextResponse.json({ error: "Valid email required" }, { status: 400 });
  }

  const referralCode = nanoid(10);

  const { data, error } = await supabase
    .from("subscribers")
    .upsert(
      {
        email: email.toLowerCase().trim(),
        referral_code: referralCode,
        referred_by: referredBy ?? null,
        source: source ?? null,
      },
      { onConflict: "email", ignoreDuplicates: false },
    )
    .select("referral_code")
    .single();

  if (error) {
    console.error("subscribe insert failed", error);
    return NextResponse.json({ error: "Try again in a moment." }, { status: 500 });
  }

  await sendConfirmationEmail(email, data.referral_code);

  return NextResponse.json({ ok: true, referralCode: data.referral_code });
}

§4. Confirmation email

Resend + a plain HTML template is all you need for the confirmation mail. Don't reach for React Email yet — one template, one style, done.

lib/email.ts
ts
import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY!);

export async function sendConfirmationEmail(email: string, code: string) {
  const confirmUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/confirm?code=${code}`;

  await resend.emails.send({
    from: process.env.RESEND_FROM_EMAIL!,
    to: email,
    subject: "One click to confirm your spot",
    html: `
      <div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:24px">
        <h1 style="font-size:22px;margin:0 0 12px">You're almost in.</h1>
        <p style="color:#555;line-height:1.55">
          Tap the button below to confirm your email and lock your place on the waitlist.
        </p>
        <a href="${confirmUrl}"
           style="display:inline-block;background:#171512;color:#fff;padding:12px 18px;border-radius:8px;text-decoration:none;margin:16px 0">
          Confirm my spot →
        </a>
        <p style="color:#888;font-size:12px">If you didn't sign up, ignore this email.</p>
      </div>
    `,
  });
}
app/api/confirm/route.ts
ts
import { NextResponse } from "next/server";
import { supabase } from "@/lib/supabase";

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const code = searchParams.get("code");
  if (!code) return NextResponse.redirect(new URL("/", req.url));

  await supabase
    .from("subscribers")
    .update({ confirmed: true })
    .eq("referral_code", code);

  return NextResponse.redirect(
    new URL(`/welcome?code=${code}`, req.url),
  );
}

§5. The landing page

Now the actual page. One headline, one sub, one input, one button. We're going to skip marketing copy — you bring that — and focus on the mechanics of form submission and the post-submit state.

app/page.tsx
tsx
"use client";

import { useState } from "react";

export default function Home() {
  const [email, setEmail] = useState("");
  const [state, setState] = useState<"idle" | "loading" | "sent" | "error">("idle");
  const [errorMsg, setErrorMsg] = useState("");

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setState("loading");
    const referredBy =
      new URLSearchParams(window.location.search).get("ref") ?? undefined;
    const res = await fetch("/api/subscribe", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ email, referredBy, source: document.referrer }),
    });
    const data = await res.json();
    if (!res.ok) {
      setErrorMsg(data.error ?? "Try again");
      setState("error");
      return;
    }
    setState("sent");
  }

  return (
    <main className="max-w-md mx-auto py-24 px-6">
      <h1 className="text-4xl font-bold tracking-tight">Your Product Name.</h1>
      <p className="mt-3 text-neutral-600">
        One line about what you're building. Join the waitlist for early access.
      </p>

      {state === "sent" ? (
        <p className="mt-8 text-green-700">
          Check your inbox — click the link to confirm your spot.
        </p>
      ) : (
        <form onSubmit={onSubmit} className="mt-8 flex gap-2">
          <input
            type="email"
            required
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            placeholder="you@work.com"
            className="flex-1 border rounded-md px-3 py-2"
          />
          <button
            type="submit"
            disabled={state === "loading"}
            className="bg-black text-white rounded-md px-4 py-2"
          >
            {state === "loading" ? "…" : "Join"}
          </button>
        </form>
      )}

      {state === "error" && (
        <p className="mt-3 text-red-600 text-sm">{errorMsg}</p>
      )}
    </main>
  );
}

Not pretty — deliberately. You'll restyle this in the next 20 minutes. The mechanics are the point: form → POST → state transition → confirmation. Run npm run dev and sign up with your own email. You should receive the confirmation email within a few seconds.

§6. Referral tracking

Referrals are tracked in two steps: (1) every subscriber has a unique code stored with their row, (2) when someone visits /?ref=CODE, we pass that code back to the API, which stores it as referred_by on the new subscriber. Our page.tsx already reads the ref param. What's left is showing the subscriber their personal referral link on the welcome page.

app/welcome/page.tsx
tsx
export default async function Welcome({
  searchParams,
}: {
  searchParams: Promise<{ code?: string }>;
}) {
  const { code } = await searchParams;
  const link = `${process.env.NEXT_PUBLIC_APP_URL}/?ref=${code ?? ""}`;

  return (
    <main className="max-w-md mx-auto py-24 px-6">
      <h1 className="text-3xl font-bold">You're confirmed. ✓</h1>
      <p className="mt-3 text-neutral-600">
        Move up the list by sharing your personal link:
      </p>
      <code className="block mt-5 p-3 bg-neutral-100 rounded-md text-sm break-all">
        {link}
      </code>
    </main>
  );
}

Next.js 16's searchParams is now a Promise — you await it. If you're on an older version you can destructure directly.

§7. Admin view

A password-gated server component that lists signups. Real admin panels are overkill for a waitlist. A protected page with a table and a CSV link is more than enough, and you'll thank yourself for not building more.

app/admin/page.tsx
tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { supabase } from "@/lib/supabase";

export default async function AdminPage() {
  const pass = (await cookies()).get("admin")?.value;
  if (pass !== process.env.ADMIN_PASSWORD) redirect("/admin/login");

  const { data: rows = [] } = await supabase
    .from("subscribers")
    .select("email, confirmed, source, created_at")
    .order("created_at", { ascending: false })
    .limit(500);

  return (
    <main className="max-w-3xl mx-auto py-16 px-6">
      <h1 className="text-2xl font-bold mb-6">
        {rows?.length ?? 0} subscribers
      </h1>
      <table className="w-full text-sm">
        <thead>
          <tr className="text-left text-neutral-500">
            <th>Email</th><th>Confirmed</th><th>Source</th><th>When</th>
          </tr>
        </thead>
        <tbody>
          {rows?.map((r) => (
            <tr key={r.email} className="border-t">
              <td className="py-2">{r.email}</td>
              <td>{r.confirmed ? "✓" : "—"}</td>
              <td className="text-neutral-500">{r.source ?? "direct"}</td>
              <td className="text-neutral-500">
                {new Date(r.created_at).toLocaleString()}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </main>
  );
}

§8. Deploy

Push to GitHub, import into Vercel, paste your env vars, hit deploy. Update NEXT_PUBLIC_APP_URL to your live URL after the first deploy and redeploy once more so confirmation links point at production.

git init
git add .
git commit -m "ship waitlist"
git remote add origin git@github.com:you/my-waitlist.git
git push -u origin main

# Then at vercel.com/new:
#   1. Import the repo
#   2. Paste the env vars from .env.local (update NEXT_PUBLIC_APP_URL)
#   3. Deploy

§9. What to do next

Now go send that URL to five people who you think might care. If any of them don't confirm their email within an hour, your template or sender reputation has an issue — check Resend's dashboard for the event log and fix whatever's wrong before you share more broadly.

  • Buy a cheap domain ($10 on Porkbun) and verify it in Resend before launch — deliverability from a fresh domain is always better than sharing one
  • Add a honey-pot field to catch bots — we skipped it, you'll want it when you post anywhere public
  • Track conversion in Plausible (or just pageviews) so you know which sources are worth doubling down on
  • Write 3 Twitter variations for the launch post before you launch — you'll panic otherwise

Shipped it but want a second pair of eyes on your copy, DNS, or email deliverability? bitroot.club does a $0 launch review for anyone who followed this guide. →