TinyKit Docs

Waitlist System

TinyKit SaaS includes a flexible waitlist system for collecting early interest before your product launch. The waitlist is part of a unified mailing list system that handles both waitlist and newsletter subscriptions in a single, efficient architecture.

🎯 Features

Public Waitlist Signup

  • Simple Email Collection: Lightweight form for capturing email addresses
  • Duplicate Detection: Automatic handling of duplicate email submissions
  • Email Validation: Server-side email format validation
  • Public Count Display: Optional waitlist count visibility for social proof
  • Reactivation Support: Automatically reactivates previously unsubscribed emails

Admin Management

  • Subscriber Dashboard: View all waitlist entries with status and timestamps
  • Settings Control: Enable/disable waitlist, toggle count visibility
  • Search & Filter: Find specific subscribers quickly
  • Status Management: Manage subscriber status (active/unsubscribed)
  • Export Capability: Download waitlist data for email campaigns

🏗️ Architecture

Resend Audiences API Integration

The waitlist uses Resend Audiences API for contact storage - there is no local contacts table. This provides:

  • External Contact Management: Contacts stored in Resend for email delivery
  • Automatic Sync: Subscribers automatically synced to Resend audience
  • No Database Bloat: No need to store email addresses locally
// Unified settings table (convex/mailingList/schema.ts)
mailingListSettings: defineTable({
  // Waitlist-specific settings
  waitlistEnabled: v.boolean(), // Enable/disable waitlist functionality
  showWaitlistCount: v.boolean(), // Show public waitlist count

  // Newsletter settings (shared system)
  newsletterEnabled: v.boolean(),
  showSubscriberCount: v.boolean(),
  allowUnsubscribe: v.boolean(),

  // Exit-intent popup settings
  exitPopupEnabled: v.boolean(),
  exitPopupTitle: v.string(),
  exitPopupDescription: v.string(),
  exitPopupButtonText: v.string(),
  exitPopupCooldown: v.number(),

  // Resend Audience API integration
  resendAudienceSyncEnabled: v.optional(v.boolean()),
  resendAudienceId: v.optional(v.string()),
  resendAppIdentifier: v.optional(v.string()),

  // Audit trail
  updatedBy: v.string(), // Better Auth user ID
  updatedAt: v.number(),
});

Benefits of Resend Integration

  • Simplified Architecture: No local contact table to maintain
  • Professional Email Delivery: Resend handles deliverability
  • Audience Segmentation: Use Resend's built-in audience features
  • Unified Management: Admin interface manages settings, Resend manages contacts

🚀 Usage

Basic Waitlist Signup Form

import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
import { toast } from "sonner";

function WaitlistForm() {
  const addToWaitlist = useMutation(
    api.mailingList.public.mutations.addToMailingList,
  );

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const email = formData.get("email") as string;

    const promise = addToWaitlist({ email });

    toast.promise(promise, {
      loading: "Joining waitlist...",
      success: "You're on the waitlist!",
      error: "Failed to join waitlist",
    });
  };

  return (
    <form onSubmit={handleSubmit} className="flex gap-2">
      <input
        type="email"
        name="email"
        placeholder="Enter your email"
        required
        className="flex-1 px-4 py-2 border rounded-md"
      />
      <button
        type="submit"
        className="px-6 py-2 bg-primary text-primary-foreground rounded-md"
      >
        Join Waitlist
      </button>
    </form>
  );
}

Display Waitlist Count

import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";

function WaitlistCounter() {
  const count = useQuery(api.mailingList.public.queries.getPublicWaitlistCount);

  if (count === null || count === undefined) {
    return null; // Count is disabled or loading
  }

  return (
    <div className="text-center">
      <p className="text-4xl font-bold text-primary">{count}</p>
      <p className="text-muted-foreground">people on the waitlist</p>
    </div>
  );
}

Check Waitlist Settings

import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";

function WaitlistStatus() {
  const settings = useQuery(api.mailingList.public.queries.getWaitlistSettings);

  if (!settings?.waitlistEnabled) {
    return <div>Waitlist is currently closed</div>;
  }

  return <WaitlistForm />;
}

⚙️ Configuration

Waitlist Settings

Access waitlist settings through the admin panel or database:

SettingDescriptionDefault
waitlistEnabledEnable/disable waitlistfalse
showWaitlistCountShow public waitlist countfalse

Admin Interface

Navigate to /admin/newsletter (unified mailing list management) to:

  1. View Subscribers: See all waitlist entries
  2. Toggle Settings: Enable/disable waitlist, show/hide count
  3. Manage Status: Update subscriber status
  4. Export Data: Download subscriber list as CSV

📊 API Reference

Public Queries

getWaitlistSettings

// Get waitlist configuration
const settings = await ctx.runQuery(
  api.mailingList.public.queries.getWaitlistSettings,
);

// Returns:
{
  waitlistEnabled: boolean;
  showWaitlistCount: boolean;
  // ... other mailing list settings
}

getPublicWaitlistCount

// Get total waitlist count (if enabled)
const count = await ctx.runQuery(
  api.mailingList.public.queries.getPublicWaitlistCount,
);

// Returns: number | null
// null if count display is disabled

Public Mutations

addToMailingList

// Add email to waitlist
const result = await ctx.runMutation(
  api.mailingList.public.mutations.addToMailingList,
  { email: "user@example.com" },
);

// Returns:
{
  success: boolean;
  entryId: Id<"mailingList">;
  action: "created" | "updated" | "exists";
}

🎨 Implementation Patterns

Landing Page Integration

function LandingPage() {
  const waitlistCount = useQuery(
    api.mailingList.public.queries.getPublicWaitlistCount,
  );
  const settings = useQuery(api.mailingList.public.queries.getWaitlistSettings);

  if (!settings?.waitlistEnabled) {
    return <ComingSoonPage />;
  }

  return (
    <div className="container mx-auto py-20">
      <h1 className="text-5xl font-bold text-center mb-4">Join the Waitlist</h1>

      {waitlistCount !== null && (
        <p className="text-center text-muted-foreground mb-8">
          Join {waitlistCount.toLocaleString()} others waiting for launch
        </p>
      )}

      <div className="max-w-md mx-auto">
        <WaitlistForm />
      </div>
    </div>
  );
}

Pre-Launch Landing Page

function PreLaunchPage() {
  return (
    <div className="min-h-screen flex items-center justify-center bg-background">
      <Card className="max-w-2xl w-full mx-4 p-8">
        <CardHeader>
          <CardTitle className="text-4xl text-center">
            Something Amazing is Coming
          </CardTitle>
          <CardDescription className="text-center text-lg">
            Be the first to know when we launch
          </CardDescription>
        </CardHeader>

        <CardContent className="space-y-6">
          <WaitlistCounter />
          <WaitlistForm />

          <p className="text-sm text-muted-foreground text-center">
            No spam. Unsubscribe anytime. We respect your privacy.
          </p>
        </CardContent>
      </Card>
    </div>
  );
}

🔄 Transition from Waitlist to Newsletter

When you launch your product, the Resend Audiences integration makes it easy to transition waitlist subscribers to newsletter:

  1. Contacts Already Synced: All waitlist emails are stored in Resend audience
  2. Update Settings: Enable newsletterEnabled in mailingListSettings
  3. Send Launch Announcement: Create a broadcast campaign targeting your audience
  4. Seamless Experience: Subscribers don't need to re-subscribe
// Example: Create launch broadcast to all waitlist subscribers
const broadcastId = await ctx.db.insert("broadcasts", {
  subject: "We're Live! 🚀",
  previewText: "The wait is over - come check out what we've built",
  content: JSON.stringify(launchEmailContent),
  status: "draft",
  sentBy: adminUserId,
  audienceId: settings.resendAudienceId, // Target the waitlist audience
  createdAt: Date.now(),
  updatedAt: Date.now(),
});

// Then send via Resend Broadcast API
await ctx.scheduler.runAfter(0, internal.mailingList.sendBroadcast, {
  broadcastId,
});

🔒 Privacy & Security

Data Handling

  • Minimal Collection: Only email addresses stored
  • Email Validation: Server-side format validation prevents invalid entries
  • Duplicate Prevention: Automatic duplicate detection
  • Unsubscribe Support: Users can request removal

Security Features

  • Rate Limiting: Protection against spam submissions
  • Email Normalization: Lowercase and trimmed emails for consistency
  • Status Tracking: Clear active/unsubscribed status management
  • Admin-Only Access: Settings protected by role-based access control

🧪 Testing

Manual Testing Checklist

  • Waitlist signup form works
  • Duplicate email handling works correctly
  • Waitlist count displays when enabled
  • Waitlist count hidden when disabled
  • Email validation prevents invalid emails
  • Admin can view all subscribers
  • Admin can toggle settings
  • Export functionality works

Test Email Formats

// Valid emails
"user@example.com";
"first.last@company.co.uk";
"test+tag@domain.io";

// Invalid emails (should be rejected)
"notanemail";
"@example.com";
"user@";
"user @example.com";

← Previous: Site Branding | Next: Newsletter →

On this page

Ship your startup faster. In minutes.

Get TinyKit SaaS