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:
| Setting | Description | Default |
|---|---|---|
waitlistEnabled | Enable/disable waitlist | false |
showWaitlistCount | Show public waitlist count | false |
Admin Interface
Navigate to /admin/newsletter (unified mailing list management) to:
- View Subscribers: See all waitlist entries
- Toggle Settings: Enable/disable waitlist, show/hide count
- Manage Status: Update subscriber status
- 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 disabledPublic 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:
- Contacts Already Synced: All waitlist emails are stored in Resend audience
- Update Settings: Enable
newsletterEnabledinmailingListSettings - Send Launch Announcement: Create a broadcast campaign targeting your audience
- 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";📚 Related Documentation
- Newsletter System - Full newsletter features and settings
- Email Templates - Customizable email templates for waitlist communications
- Admin Dashboard - Admin interface for subscriber management