Mailing List System
TinyKit Pro includes a unified mailing list system with waitlist collection, newsletter subscriptions, exit-intent popups, and Resend Audiences integration.
TinyKit Pro includes a unified mailing list system that handles both pre-launch waitlist collection and newsletter subscriptions in a single, efficient architecture using Resend Audiences API.
Overview
The mailing list system provides:
- Waitlist Mode: Collect early interest before product launch
- Newsletter Mode: Engage subscribers after launch
- Seamless Transition: Convert waitlist subscribers to newsletter without re-signup
- Exit-Intent Popups: Capture leaving visitors with configurable popups
- Resend Integration: Professional email delivery via Resend Audiences API
Architecture
Resend Audiences Integration
The system uses Resend Audiences API for contact storage - there is no local contacts table. This provides:
- External Contact Management: Contacts stored in Resend for professional email delivery
- Automatic Sync: Subscribers automatically synced to Resend audience
- No Database Bloat: Email addresses managed externally
- Audience Segmentation: Use Resend's built-in audience features
Database Schema
// Unified settings (convex/mailingList/schema.ts)
mailingListSettings: defineTable({
// Waitlist settings
waitlistEnabled: v.boolean(),
showWaitlistCount: v.boolean(),
// Newsletter settings
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(), // Days before reshowing
// Time-delayed popup
onPagePopupEnabled: v.optional(v.boolean()),
onPagePopupDelay: v.optional(v.number()), // Milliseconds
// 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(),
});
// Broadcast campaigns
broadcasts: defineTable({
subject: v.string(),
previewText: v.string(),
content: v.string(), // JSON-stringified newsletter sections
status: broadcastStatusValidator, // "draft", "scheduled", "sending", "sent", "failed"
scheduledFor: v.optional(v.number()),
sentAt: v.optional(v.number()),
sentBy: v.string(), // Better Auth user ID
resendBroadcastId: v.optional(v.string()),
audienceId: v.optional(v.string()),
recipientCount: v.optional(v.number()),
errorMessage: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_status", ["status"])
.index("by_sentBy", ["sentBy"])
.index("by_sentAt", ["sentAt"])
.index("by_createdAt", ["createdAt"]);Waitlist Features
Public Waitlist Signup
- Simple Email Collection: Lightweight form for capturing email addresses
- Duplicate Detection: Automatic handling of duplicate submissions
- Email Validation: Server-side email format validation
- Public Count Display: Optional waitlist count for social proof
- Reactivation Support: Automatically reactivates previously unsubscribed emails
Basic Waitlist 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>
);
}Newsletter Features
Exit-Intent & Timed Popups
- Exit-Intent Detection: Shows popup when users attempt to leave
- Timed Popup: Optional time-based popup after specified delay
- Smart Triggers: Separate logic for exit-intent vs. on-page popups
- Cookie-Based Tracking: Prevents showing popups to existing subscribers
Newsletter Signup Component
import { NewsletterSignup } from "@/features/mailing-list/newsletter-signup";
export function Footer() {
return (
<NewsletterSignup
variant="compact"
source="footer"
showTitle={false}
customDescription="Get updates on new features and releases"
/>
);
}Exit-Intent Popup
import { NewsletterExitPopup } from "@/features/mailing-list/newsletter-exit-popup";
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<NewsletterExitPopup />
</body>
</html>
);
}Configuration
Settings Reference
| Setting | Description | Default |
|---|---|---|
waitlistEnabled | Enable waitlist mode | false |
showWaitlistCount | Show public count | false |
newsletterEnabled | Enable newsletter system | true |
showSubscriberCount | Show subscriber count | false |
allowUnsubscribe | Allow unsubscribe | true |
exitPopupEnabled | Enable exit-intent popup | false |
exitPopupTitle | Popup headline text | "Before You Go!" |
exitPopupDescription | Popup description | "Join our newsletter..." |
exitPopupButtonText | Subscribe button text | "Subscribe" |
exitPopupCooldown | Days before reshowing | 7 |
onPagePopupEnabled | Enable time-based popup | false |
onPagePopupDelay | Delay in milliseconds | 30000 |
Admin Interface
Navigate to /admin/newsletter for unified mailing list management:
- Subscribers Tab: View all subscriptions with status, source, and dates
- Settings Tab: Configure waitlist/newsletter behavior
- Broadcasts Tab: Create and send email campaigns
- Analytics: Track growth rates and engagement
Cookie-Based Tracking
The system uses a single JSON cookie (newsletter_state) to track:
interface NewsletterState {
subscribed?: boolean; // User has subscribed
dismissedAt?: number; // When popup was last dismissed
shownCount?: number; // How many times popup was shown
}Cookie Functions
import { newsletterCookies } from "@/lib/newsletter-cookies";
// Check if should show popup
const canShow = newsletterCookies.shouldShowPopup();
// Mark user as subscribed
newsletterCookies.markAsSubscribed();
// Track dismissal
newsletterCookies.markAsDismissed();
// Check subscription status
const isSubscribed = newsletterCookies.isSubscribed();Transition from Waitlist to Newsletter
When you launch, the Resend Audiences integration makes transition seamless:
- Contacts Already Synced: All waitlist emails stored in Resend audience
- Update Settings: Enable
newsletterEnabledin admin panel - Send Launch Announcement: Create broadcast targeting your audience
- Seamless Experience: Subscribers don't need to re-subscribe
// 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,
createdAt: Date.now(),
updatedAt: Date.now(),
});
// Send via Resend Broadcast API
await ctx.scheduler.runAfter(0, internal.mailingList.sendBroadcast, {
broadcastId,
});API Reference
Public Queries
// Get waitlist/newsletter settings
const settings = await ctx.runQuery(
api.mailingList.public.queries.getWaitlistSettings,
);
// Get public waitlist count (if enabled)
const count = await ctx.runQuery(
api.mailingList.public.queries.getPublicWaitlistCount,
);
// Returns: number | null (null if display is disabled)Public Mutations
// Add email to mailing list
const result = await ctx.runMutation(
api.mailingList.public.mutations.addToMailingList,
{ email: "user@example.com" },
);
// Returns:
{
success: boolean;
entryId: Id<"mailingList">;
action: "created" | "updated" | "exists";
}Admin Functions
getNewsletterStatsAdmin- Subscription statisticsgetAllNewsletterSubscriptionsAdmin- All subscriptionssearchNewsletterSubscriptionsAdmin- Search subscribersupdateNewsletterSettings- Update settingsupdateNewsletterStatus- Change subscription statusdeleteNewsletterSubscription- Delete subscriptionbulkImportNewsletterEmails- Import multiple subscribers
Analytics & Insights
Subscription Sources
Track where subscribers come from:
footer- Footer signup formexit-popup- Exit-intent popuplanding-page- Landing page formadmin_import- Bulk imported by admin- Custom sources via
sourceprop
Dashboard Metrics
- Total Subscribers: Active subscription count
- Growth Rate: Recent subscription velocity
- Source Breakdown: Subscription origin analysis
- Status Distribution: Active vs. unsubscribed ratios
Privacy & Security
Data Handling
- Minimal Collection: Only email addresses and metadata
- Email Validation: Server-side format validation
- Duplicate Prevention: Automatic duplicate detection
- Unsubscribe Support: One-click unsubscribe functionality
Security Features
- Rate Limiting: Protection against spam submissions
- Email Normalization: Lowercase and trimmed for consistency
- Admin-Only Access: Settings protected by role-based access control
- GDPR Compliant: Data export and deletion support
Implementation Patterns
Pre-Launch Landing Page
function PreLaunchPage() {
const settings = useQuery(api.mailingList.public.queries.getWaitlistSettings);
if (!settings?.waitlistEnabled) {
return <ComingSoonPage />;
}
return (
<div className="min-h-screen flex items-center justify-center">
<Card className="max-w-2xl w-full mx-4 p-8">
<CardHeader>
<CardTitle className="text-4xl text-center">
Something Amazing is Coming
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<WaitlistCounter />
<WaitlistForm />
<p className="text-sm text-muted-foreground text-center">
No spam. Unsubscribe anytime.
</p>
</CardContent>
</Card>
</div>
);
}Newsletter with Visual Effects
<NewsletterSignup
variant="card"
source="landing-page"
customTitle="Stay in the Loop"
customDescription="Get notified about new features and updates"
showBorderEffects={true}
showConfetti={true}
/>Testing Checklist
- Waitlist signup form works
- Newsletter signup form works
- Duplicate email handling works correctly
- Count displays when enabled
- Exit-intent popup triggers correctly
- Timed popup appears after delay
- Cookie tracking prevents duplicate popups
- Admin settings save and apply
- Subscriber management functions work
- CSV export contains correct data
- Unsubscribe flow works properly
- Resend audience sync works
Email Template System
TinyKit Pro includes a comprehensive email template system built with React Email, providing consistent, professional email communications across all notific...
FAQs Management
TinyKit Pro includes a comprehensive Frequently Asked Questions (FAQ) management system with admin controls for creating, updating, ordering, and publishing ...