back

waitlist

Scaffold a waitlist with storage, duplicate handling, confirmation email, admin access, and optional referrals. Use when collecting signups before or during launch.

Category
marketing
Package
waitlist/SKILL.md
License
MIT
Author
@tushaarmehtaa
Tags
waitlistemaillaunchearly-accessresendsupabase

Install

Swipe for more runtimes.

Codex

Skills directory: ~/.codex/skills

available to install

Install globally

$npx skills add tushaarmehtaa/tushar-skills --skill waitlist -g -a codex -y

Invoke

$waitlist or /skills

You can also describe the task naturally; runtimes may select the skill from its description.

Required access

project filesterminal commandsnetwork access

local coding agent required

This skill requires project files, terminal commands, and network access. Uploading it to a chat app does not provide equivalent execution.

ChatGPT Skills

This workflow needs a local coding environment or capabilities that a chat-only Skills upload does not provide.

Why local agent required →

Instructions

Source: SKILL.md

Scaffold a complete waitlist — email capture, storage, confirmation email, and an admin view. Reads the project first, wires into existing stack.

Phase 1: Detect the Project

cat package.json | grep -E "next|supabase|prisma|drizzle|resend"

Check for:

  • Database: Supabase / Prisma / Drizzle / raw Postgres?
  • Email: Resend already installed?
  • Auth: Any auth provider? (Admin view needs it)
  • Existing waitlist table? Search for waitlist in schema files

Phase 2: Ask the User

I'll scaffold a waitlist for [product name].

Quick decisions:

1. What info do you want to collect?
   a) Email only (default — highest conversion)
   b) Email + name
   c) Email + name + "what are you building?" (optional field)

2. Referral tracking?
   a) No — simple list
   b) Yes — each signup gets a unique referral link, referred count tracked

3. Confirmation email?
   a) Yes — send a "you're on the list" email via Resend (default)
   b) No

4. Do you have Resend and a verified sending domain? (yes / need to set up)

Phase 3: Database Schema

Adapt to the detected database. Create a waitlist table:

Supabase / raw SQL:

create table public.waitlist (
  id uuid primary key default gen_random_uuid(),
  email text not null unique,
  name text,
  note text,                              -- optional "what are you building?" field
  referral_code text unique,              -- their shareable code (if referral enabled)
  referred_by text references public.waitlist(referral_code),  -- who sent them
  referral_count integer default 0,       -- how many they've referred
  position integer,                       -- queue position (set by trigger)
  confirmed_at timestamptz,               -- null = unconfirmed
  created_at timestamptz default now()
);

-- Auto-increment position on insert
create or replace function set_waitlist_position()
returns trigger as $$
begin
  new.position = (select coalesce(max(position), 0) + 1 from public.waitlist);
  return new;
end;
$$ language plpgsql;

create trigger waitlist_position_trigger
  before insert on public.waitlist
  for each row execute function set_waitlist_position();

If no referral tracking needed, drop referral_code, referred_by, referral_count.

Prisma:

model Waitlist {
  id           String    @id @default(cuid())
  email        String    @unique
  name         String?
  note         String?
  referralCode String?   @unique
  referredBy   String?
  referralCount Int      @default(0)
  position     Int
  confirmedAt  DateTime?
  createdAt    DateTime  @default(now())
}

Phase 4: The Signup API Route

// app/api/waitlist/route.ts
import { NextResponse } from 'next/server';

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

  // Validate email
  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return NextResponse.json({ error: 'Valid email required' }, { status: 400 });
  }

  // Normalize
  const normalizedEmail = email.toLowerCase().trim();

  // Check for duplicate
  const existing = await db.query.waitlist.findFirst({
    where: eq(waitlist.email, normalizedEmail),
  });

  if (existing) {
    // Return success — don't tell spammers which emails are registered
    return NextResponse.json({
      success: true,
      position: existing.position,
      alreadyRegistered: true,
    });
  }

  // Generate referral code if enabled
  const referralCode = generateReferralCode(normalizedEmail);  // e.g., first 8 chars of email hash

  // Insert
  const entry = await db.insert(waitlistTable).values({
    email: normalizedEmail,
    name: name?.trim() || null,
    note: note?.trim() || null,
    referralCode,
    referredBy: referredBy || null,
  }).returning();

  // Increment referrer's count
  if (referredBy) {
    await db.update(waitlistTable)
      .set({ referralCount: sql`referral_count + 1` })
      .where(eq(waitlistTable.referralCode, referredBy));
  }

  // Send confirmation email (if Resend is set up)
  if (process.env.RESEND_API_KEY) {
    await sendWaitlistConfirmation({
      email: normalizedEmail,
      name: name || 'there',
      position: entry[0].position,
      referralCode,
    });
  }

  return NextResponse.json({
    success: true,
    position: entry[0].position,
    referralCode,
  });
}

function generateReferralCode(email: string): string {
  const { createHash } = require('crypto');
  return createHash('sha256').update(email).digest('hex').slice(0, 8);
}

Phase 5: Confirmation Email

// lib/emails/waitlist-confirmation.ts
import { Resend } from 'resend';

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

export async function sendWaitlistConfirmation({
  email, name, position, referralCode,
}: { email: string; name: string; position: number; referralCode?: string }) {
  const referralUrl = referralCode
    ? `${process.env.NEXT_PUBLIC_APP_URL}/?ref=${referralCode}`
    : null;

  await resend.emails.send({
    from: `[Product Name] <hi@yourdomain.com>`,
    to: email,
    subject: `you're #${position} on the list`,
    html: `
      <div style="font-family: sans-serif; max-width: 520px; margin: 0 auto; padding: 40px 20px; color: #111;">
        <p>Hey ${name},</p>
        <p>You're <strong>#${position}</strong> on the waitlist. We'll reach out when you're up.</p>
        ${referralUrl ? `
        <p style="margin-top: 24px;">
          Move up the list — share your link:<br>
          <a href="${referralUrl}" style="color: #000; font-weight: bold;">${referralUrl}</a>
        </p>
        ` : ''}
        <p style="color: #666; font-size: 13px; margin-top: 40px;">— [Your Name]</p>
      </div>
    `,
  });
}

Phase 6: The Signup Form Component

// components/waitlist-form.tsx
'use client';
import { useState } from 'react';

export function WaitlistForm({ referralCode }: { referralCode?: string }) {
  const [email, setEmail] = useState('');
  const [state, setState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
  const [position, setPosition] = useState<number | null>(null);
  const [userReferralCode, setUserReferralCode] = useState<string | null>(null);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setState('loading');

    const res = await fetch('/api/waitlist', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, referredBy: referralCode }),
    });

    const data = await res.json();

    if (data.success) {
      setState('success');
      setPosition(data.position);
      setUserReferralCode(data.referralCode);
    } else {
      setState('error');
    }
  }

  if (state === 'success') {
    return (
      <div>
        <p>you're #{position} on the list.</p>
        {userReferralCode && (
          <p>
            share your link to move up:{' '}
            <code>{`${window.location.origin}/?ref=${userReferralCode}`}</code>
          </p>
        )}
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="your@email.com"
        required
        disabled={state === 'loading'}
      />
      <button type="submit" disabled={state === 'loading'}>
        {state === 'loading' ? 'joining...' : 'join waitlist'}
      </button>
      {state === 'error' && <p>something went wrong. try again.</p>}
    </form>
  );
}

Read the referral code from URL params in the page:

// app/page.tsx
export default function Page({ searchParams }: { searchParams: { ref?: string } }) {
  return <WaitlistForm referralCode={searchParams.ref} />;
}

Phase 7: Admin View

Simple route that requires a secret header — no full auth needed for a waitlist admin:

// app/api/admin/waitlist/route.ts
export async function GET(req: Request) {
  if (req.headers.get('x-admin-secret') !== process.env.ADMIN_SECRET) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const signups = await db.select().from(waitlistTable).orderBy(asc(waitlistTable.position));

  return Response.json({
    total: signups.length,
    signups,
  });
}

Fetch with: curl -H "x-admin-secret: $ADMIN_SECRET" https://yoursite.com/api/admin/waitlist

Verify

[ ] Duplicate email returns success with alreadyRegistered: true (no error, no leak)
[ ] Email normalized to lowercase before storage
[ ] Position increments correctly — first signup is #1
[ ] Confirmation email arrives in inbox, not spam
[ ] Referral code in URL (?ref=) is captured and stored on signup
[ ] Referrer's count increments when referred signup completes
[ ] Admin route requires x-admin-secret header
[ ] ADMIN_SECRET and RESEND_API_KEY in .env.example
[ ] Form disables input during submission
[ ] Success state shows position and referral link