Build an Email Newsletter SaaS
Build a newsletter SaaS with Next.js, PostgreSQL and a mail provider: signup forms, campaigns, subscriber management and analytics.
Build your own newsletter platform: embeddable signup forms, subscriber lists, double opt-in, a chunked campaign sender and open/click analytics — without Mailchimp pricing. Every function referenced below exists below.
What we’re building
flowchart LR
Reader --> Form[Embedded signup form]
Form --> API[Next.js API]
API --> PG[(PostgreSQL)]
Reader --> Confirm[Confirm link]
Confirm --> API2[Verify route]
Author --> Composer[Campaign composer]
Composer --> MTA[Mail provider]
MTA --> Reader
MTA -->|events| Webhook[Tracking webhook]
Webhook --> PG
Subs confirm via a token emailed to them (double opt-in), campaigns send in a throttled loop, and the provider’s open/click/bounce events land in tracking through a webhook.
What you’ll learn
- A subscriber lifecycle that respects deliverability (real double opt-in, not a fake token)
- Campaign delivery that doesn’t get your domain blacklisted
- Open/click tracking and bounce handling without selling data
Prerequisites
- Node.js 22+
- PostgreSQL 17
- A mail provider (Resend, SES or Brevo — this guide uses Resend for its simple API)
- A domain you own (mandatory for email deliverability)
1. Scaffold
npx create-next-app@latest newsletter --ts --app
cd newsletter
npm i pg zod resend
resend replaces the phantom send/sendWithTracking helpers older copies of this guide assumed — all sending goes through one real provider SDK.
2. Schema
CREATE TABLE subscribers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | active | unsubscribed | bounced
confirm_token TEXT NOT NULL, -- random token for the opt-in link
source TEXT DEFAULT 'form',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE campaigns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subject TEXT NOT NULL,
html_body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft', -- draft | sending | sent
sent_at TIMESTAMPTZ
);
CREATE TABLE campaign_sends (
campaign_id UUID REFERENCES campaigns(id),
subscriber_id UUID REFERENCES subscribers(id),
status TEXT NOT NULL DEFAULT 'queued', -- queued | sent | opened | clicked | bounced
PRIMARY KEY (campaign_id, subscriber_id)
);
CREATE TABLE tracking (
id BIGSERIAL PRIMARY KEY,
campaign_id UUID,
subscriber_id UUID,
event TEXT NOT NULL, -- sent | opened | clicked | bounced
at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Apply: psql "$DATABASE_URL" -f schema.sql.
3. Environment variables
.env (project root; not committed):
DATABASE_URL=postgres://postgres:dev@localhost:5432/newsletter
RESEND_API_KEY=re_...
APP_URL=http://localhost:3000
EMAIL_FROM="Newsletter <[email protected]>"
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
yes | PostgreSQL connection string. |
RESEND_API_KEY |
yes | resend.com → API keys. |
APP_URL |
yes | Public base URL for confirm links. localhost in dev, your domain in prod. |
EMAIL_FROM |
yes | Verified sender. The domain must pass SPF/DKIM/DMARC in Resend’s dashboard first. |
4. Double opt-in, for real
The token is generated, stored, sent, and later verified by a route that flips the subscriber to active. Three functions, all implemented:
lib/db.ts:
import { Pool } from 'pg';
const globalForPg = globalThis as unknown as { pool?: Pool };
export const pool =
globalForPg.pool ??
new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
if (process.env.NODE_ENV !== 'production') globalForPg.pool = pool;
lib/email.ts:
import { Resend } from 'resend';
export const resend = new Resend(process.env.RESEND_API_KEY);
export async function sendEmail(to: string, subject: string, html: string) {
const { error } = await resend.emails.send({
from: process.env.EMAIL_FROM!,
to,
subject,
html,
});
if (error) throw new Error(`resend: ${error.message}`);
}
app/api/subscribe/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { randomBytes } from 'node:crypto';
import { z } from 'zod';
import { pool } from '@/lib/db';
import { sendEmail } from '@/lib/email';
const bodySchema = z.object({ email: z.string().email() });
export async function POST(req: NextRequest) {
try {
const parsed = bodySchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const email = parsed.data.email.toLowerCase().trim();
const existing = await pool.query(
'SELECT status, confirm_token FROM subscribers WHERE email = $1', [email]
);
if (existing.rowCount) {
const row = existing.rows[0];
if (row.status === 'unsubscribed') {
// A re-subscribe must be explicit; require them to opt back in via the form.
await pool.query(
"UPDATE subscribers SET status='pending', confirm_token=$2 WHERE email=$1",
[email, randomBytes(32).toString('hex')]
);
}
return NextResponse.json({ ok: true, message: 'confirmation sent' });
}
const token = randomBytes(32).toString('hex');
await pool.query(
`INSERT INTO subscribers (email, status, confirm_token)
VALUES ($1, 'pending', $2)`,
[email, token]
);
const url = `${process.env.APP_URL}/api/confirm?email=${encodeURIComponent(email)}&t=${token}`;
await sendEmail(email, 'Confirm your subscription', `Click to confirm: <a href="${url}">${url}</a>`);
return NextResponse.json({ ok: true });
} catch (err) {
console.error('subscribe failed', err);
return NextResponse.json({ error: 'internal error' }, { status: 500 });
}
}
app/api/confirm/route.ts — the missing piece that makes opt-in real:
import { NextRequest, NextResponse } from 'next/server';
import { pool } from '@/lib/db';
export async function GET(req: NextRequest) {
const email = req.nextUrl.searchParams.get('email');
const token = req.nextUrl.searchParams.get('t');
if (!email || !token) {
return NextResponse.json({ error: 'missing parameters' }, { status: 400 });
}
const result = await pool.query(
`UPDATE subscribers
SET status = 'active', confirm_token = ''
WHERE email = $1 AND confirm_token = $2 AND status != 'bounced'
RETURNING id`,
[email.toLowerCase().trim(), token]
);
if (!result.rowCount) {
return NextResponse.json({ error: 'invalid or expired token' }, { status: 400 });
}
return NextResponse.redirect(new URL('/confirmed', process.env.APP_URL!));
}
What this changes vs. the “fake” version:
- The token now lives in the database and is revoked by clearing it on activation — re-using an old confirm link after activation fails.
- An unknown email, wrong token, or bounced address all fail the
UPDATE ... WHEREand get400— no silent “ok”. - Unsubscribers never receive mail again automatically; their re-subscription creates a fresh
pendingcycle through the same flow.
5. Sending a campaign
app/api/campaigns/send/route.ts — throttled, one provider call at a time, tracked:
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { pool } from '@/lib/db';
import { resend } from '@/lib/email';
const bodySchema = z.object({
campaignId: z.string().uuid(),
subject: z.string().min(1),
htmlBody: z.string().min(1),
});
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function POST(req: NextRequest) {
let campaign;
try {
const parsed = bodySchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
campaign = parsed.data;
const subs = await pool.query(
`SELECT s.id, s.email FROM subscribers s
JOIN campaign_sends cs ON cs.subscriber_id = s.id
WHERE cs.campaign_id = $1 AND cs.status = 'queued'`,
[campaign.campaignId]
);
await pool.query(
`UPDATE campaigns SET status='sending' WHERE id=$1`, [campaign.campaignId]
);
// Sequential + paced: a burst lands in the spam folder.
for (const s of subs.rows) {
try {
await resend.emails.send({
from: process.env.EMAIL_FROM!,
to: s.email,
subject: campaign.subject,
html: campaign.htmlBody,
});
await pool.query(
`UPDATE campaign_sends SET status='sent' WHERE campaign_id=$1 AND subscriber_id=$2`,
[campaign.campaignId, s.id]
);
} catch (err) {
console.error('send failed', s.email, err);
// Mark bounced so the loop moves on; a failure per row must not kill the campaign.
await pool.query(
`UPDATE campaign_sends SET status='bounced' WHERE campaign_id=$1 AND subscriber_id=$2`,
[campaign.campaignId, s.id]
);
}
await sleep(100); // ~10 emails/s — adjust to your domain's warm state
}
await pool.query(
`UPDATE campaigns SET status='sent', sent_at=now() WHERE id=$1`, [campaign.campaignId]
);
return NextResponse.json({ ok: true, attempted: subs.rowCount });
} catch (err) {
console.error('campaign send failed', err);
return NextResponse.json({ error: 'internal error' }, { status: 500 });
}
}
Why it’s built this way:
- Paced: 100 ms between sends is the safe default for a cold domain. Free inboxes flag sudden volume; ramp up as your sender reputation warms.
- Per-row failure isolation: a single bad address marks that send
bouncedand the campaign continues — a batch API call would fail the whole thing (that’s why this guide avoidsresend.batchfor the long tail). - The
campaign_sendsrows are created ahead of time when the campaign is drafted (INSERT … SELECT from active subscribers), so a reload of this route resumes where it left off instead of double-sending.
6. Tracking webhook
app/api/webhooks/tracking/route.ts (configure the provider to POST delivery events here):
import { NextRequest, NextResponse } from 'next/server';
import { pool } from '@/lib/db';
export async function POST(req: NextRequest) {
try {
const events = await req.json(); // Resend webhook array of {type, email, created_at, metadata?}
const list = Array.isArray(events) ? events : [events];
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const e of list) {
const type = e.type.endsWith('.clicked') ? 'clicked'
: e.type.endsWith('.opened') ? 'opened'
: e.type.includes('bounce') ? 'bounced' : 'sent';
await client.query(
`INSERT INTO tracking (campaign_id, subscriber_id, event, at)
SELECT cs.campaign_id, cs.subscriber_id, $2, now()
FROM campaign_sends cs
WHERE cs.subscriber_id = (SELECT id FROM subscribers WHERE email = $1)`,
[e.email, type]
);
if (type === 'bounced') {
await client.query(
`UPDATE subscribers SET status='bounced' WHERE email=$1`, [e.email]
);
}
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
return NextResponse.json({ ok: true });
} catch (err) {
console.error('tracking webhook failed', err);
return NextResponse.json({ error: 'internal error' }, { status: 500 });
}
}
The event name is mapped to the four states the schema understands, and bounces demote subscribers so the next campaign skip them.
7. Run locally
docker run -d --name pg --rm -p 5432:5432 -e POSTGRES_PASSWORD=dev postgres:17
export DATABASE_URL=postgres://postgres:dev@localhost:5432/postgres
psql "$DATABASE_URL" -f schema.sql
export $(grep -v '^#' .env | xargs)
npm run dev
Test the full flow:
curl -s -X POST http://localhost:3000/api/subscribe \
-H 'content-type: application/json' -d '{"email":"[email protected]"}'
# {"ok":true}
# the email lands in your Resend dashboard with a ?t=... link; fake-verify by copying it:
curl -s -L "http://localhost:3000/api/[email protected]&t=<TOKEN_FROM_RESEND>"
In Resend’s sandbox the confirm email only delivers to the address you verified in your account — use that address in the test.
8. Dockerize and deploy
Dockerfile (requires output: 'standalone' in next.config.ts):
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
On the VPS:
docker build -t newsletter .
docker run -d --name nl --restart unless-stopped -p 3000:3000 \
-e DATABASE_URL=... -e RESEND_API_KEY=... \
-e APP_URL=https://YOUR-DOMAIN.com \
-e EMAIL_FROM='Newsletter <[email protected]>' newsletter
Add Caddy (reverse_proxy app:3000) with your real domain and set the Resend webhook URL to https://YOUR-DOMAIN.com/api/webhooks/tracking.
Cost (assumptions: 5k subscribers, 4 campaigns/month)
| Service | Monthly cost |
|---|---|
| VPS 1–2 GB | $4.49 |
| Resend (up to 3k emails free, then $20/50k) | $0–20 |
| Total | ≈ $5–25/month |
Mailchimp’s equivalent 5k-subscriber plan is around $105/month. Yours is the VPS price plus the provider’s email fee.
Production checklist
- SPF, DKIM and DMARC set on your sending domain (Resend dashboard shows each record)
- Double opt-in enforced (implemented above — the confirm token is stored and checked)
- Bounce + complaint webhooks wired to the tracking route
- Unsubscribe link with token in every campaign footer (
/api/unsubscribe?t=...— same token model as confirm) - Campaign sends resume-safe (statuses are per-row in
campaign_sends) - Monthly export endpoint of the list (portability:
SELECT email FROM subscribers WHERE status='active')
Common problems
- Every email in spam: missing SPF/DKIM/DMARC, a bought/expired domain, or sending 100k emails from a cold sender. Warm up with small daily sends first.
- List rot: bounces auto-demote (webhook above); clean monthly — a 20%+ bounce rate poisons your sender reputation.
- Re-subscribing unsubscribers: prohibited in the subscribe route — they get a fresh opt-in cycle or nothing.
- Heroku-style ephemeral tokens: none here — tokens are in Postgres and revoked on first use, so confirm links can’t be replayed.
Improvements
- Automated welcome sequence (5 emails over 10 days, driven by
created_at) - AI subject-line suggestions with open-rate feedback from the
trackingtable - RSS-to-newsletter importer that drafts a campaign from a feed
Conclusion
A newsletter SaaS is a subscriber state machine, a throttled send loop and an events webhook — all real code above. $5/month replaces a $100/month tool, and with double opt-in plus proper DNS records your deliverability beats the giants.