TinyKit Pro Docs

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

SettingDescriptionDefault
waitlistEnabledEnable waitlist modefalse
showWaitlistCountShow public countfalse
newsletterEnabledEnable newsletter systemtrue
showSubscriberCountShow subscriber countfalse
allowUnsubscribeAllow unsubscribetrue
exitPopupEnabledEnable exit-intent popupfalse
exitPopupTitlePopup headline text"Before You Go!"
exitPopupDescriptionPopup description"Join our newsletter..."
exitPopupButtonTextSubscribe button text"Subscribe"
exitPopupCooldownDays before reshowing7
onPagePopupEnabledEnable time-based popupfalse
onPagePopupDelayDelay in milliseconds30000

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

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
}
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:

  1. Contacts Already Synced: All waitlist emails stored in Resend audience
  2. Update Settings: Enable newsletterEnabled in admin panel
  3. Send Launch Announcement: Create broadcast targeting your audience
  4. 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 statistics
  • getAllNewsletterSubscriptionsAdmin - All subscriptions
  • searchNewsletterSubscriptionsAdmin - Search subscribers
  • updateNewsletterSettings - Update settings
  • updateNewsletterStatus - Change subscription status
  • deleteNewsletterSubscription - Delete subscription
  • bulkImportNewsletterEmails - Import multiple subscribers

Analytics & Insights

Subscription Sources

Track where subscribers come from:

  • footer - Footer signup form
  • exit-popup - Exit-intent popup
  • landing-page - Landing page form
  • admin_import - Bulk imported by admin
  • Custom sources via source prop

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

← Previous: Testimonials | Next: Site Branding →

On this page

Ship your startup faster. In minutes.

Get TinyKit Pro