codexmachina
App schema

Newsletter platform, built on every verified stack.

Audience-scoped newsletter platform: subscribers with deliverability status, named lists, list membership, campaigns scheduled against a list, and per-subscriber send tracking.

40 verified stacks40 greenLast updated 2026-08-23How we verify →

40 verified stacks

Showing 12 across 1 app types. Every stack page links its neighbours along each axis, so any combination is a click or two from here.

Next.js 16 (App Router) + MySQL 8Auth.js (NextAuth) · Resend
Next.js 16 (App Router) + MySQL 8Better Auth
Next.js 16 (App Router) + MySQL 8Clerk
Next.js 16 (App Router) + MySQL 8Supabase Auth
Next.js 16 (App Router) + Postgres (Neon)Auth.js (NextAuth)
Next.js 16 (App Router) + Postgres (Neon)Better Auth
Next.js 16 (App Router) + Postgres (Neon)Clerk
Next.js 16 (App Router) + Postgres (Neon)Supabase Auth
Nuxt 4 + MySQL 8Better Auth
Nuxt 4 + MySQL 8Clerk · Resend
Nuxt 4 + Postgres (Neon)Supabase Auth
React Router v8 + MySQL 8Better Auth · Resend

What Newsletter platform gives you

Every row in this schema traces back to one owner_id. subscribers.owner_id and lists.owner_id are text FKs straight to Better Auth's user.id — no organization or workspace table sits in between — so an audience belongs to a single account, and a team-shared list means adding a tenant layer rather than adjusting a column. idx_subscriber_owner and idx_list_owner are what keep those owner-scoped reads cheap. Ownership also decides the constraint that matters most on import. subscribers_owner_email_unique covers (owner_id, email), not email alone: the same address can sit on two operators' audiences, and a CSV import dedupes with an upsert on that pair. An address is not a global key here, which is right for a multi-tenant product and wrong the moment code assumes otherwise.

Nothing enforces that a subscriber and the list they join share an owner either — list_subscriptions references lists.id and subscribers.id, not a common owner — so cross-owner membership is prevented in the handler, not the database. Membership and delivery are both join-shaped and constrained differently. list_subscriptions carries list_subscriptions_list_subscriber_unique on (list_id, subscriber_id), placing a subscriber on a list at most once; the leading list_id turns a recipient set into a prefix scan, and idx_list_subscription_subscriber runs it the other way for a preference page listing every list one address is on. A campaign points at the list it targets through campaigns.list_id, indexed by idx_campaign_list, and walks 'draft' to 'scheduled' to 'sent' under campaigns_status_check with scheduled_at set for the middle state.

campaign_sends has no matching unique — its only index is idx_send_campaign — so a re-run of a fan-out will insert a second row for the same recipient, and idempotency on the enqueue side is yours to write. The send row doubles as the metrics table. campaigns holds no counters, so delivery and open numbers are a group by status over campaign_sends, whose CHECK pins the vocabulary to 'queued', 'delivered', 'opened' and 'bounced'. That row is written when queued (there is no created_at, only a nullable sent_at filled in later) and updated in place. Because campaign_sends.subscriber_id cascades on delete, hard-deleting a subscriber rewrites the history of every campaign they received; flipping subscribers.status to 'unsubscribed' is the move that keeps the numbers intact.

Built for

Everything one operator owns

idx_subscriber_owner and idx_list_owner both index owner_id, the text FK to user.id, so a dashboard scoped to the signed-in account reads its subscribers and its lists off two single-column indexes.

Importing addresses without duplicating them

subscribers_owner_email_unique on (owner_id, email) is the upsert target for a CSV import. Dedup is per owner rather than global — the same address on another operator's audience is a separate subscribers row.

Assembling a broadcast's recipient set

list_subscriptions_list_subscriber_unique leads with list_id, so one list's membership rows are a prefix scan; join to subscribers and filter status = 'subscribed' to drop unsubscribed and bounced addresses.

A subscriber's preference page

idx_list_subscription_subscriber on list_subscriptions.subscriber_id inverts the join to every list one address belongs to, which is what both an unsubscribe-all and a per-list opt-out read.

Delivery and open rates for a campaign

idx_send_campaign on campaign_sends.campaign_id backs a group by status over the four states campaign_sends_status_check allows. campaigns carries no counter columns, so the rollup is always computed from the send rows themselves.

Why Newsletter platform, specifically

note

The (ownerId, email) unique constraint on subscribers prevents the same address appearing twice on one owner's audience — deduplication is enforced at the DB level, not application code.

note

campaign_sends holds one row per (campaign, subscriber) fan-out, each walking queued → delivered → opened → bounced via a text CHECK, making delivery/open rollups a straight aggregate over the idx_send_campaign index rather than a mutable counter. Note there is NO unique on (campaign_id, subscriber_id) — only idx_send_campaign — so re-running a fan-out inserts a second row for the same subscriber; deduplication belongs to the enqueue path, not the database.