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 ... WHERE and get 400 — no silent “ok”.
  • Unsubscribers never receive mail again automatically; their re-subscription creates a fresh pending cycle 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 bounced and the campaign continues — a batch API call would fail the whole thing (that’s why this guide avoids resend.batch for the long tail).
  • The campaign_sends rows 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 tracking table
  • 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.

Related guides