Newsletter platform on Next.js 16 (App Router) + Postgres (Neon) + Clerk

✓ Verified 2026-06-21 · next 16.2.9 · postgres 3.4.9 · @clerk/nextjs 7.5.7

Type-checked against the real SDKs, migration applied to a live Postgres, pooling tested — then tracked for upstream drift and re-verified when it moves. How we verify →

What you're getting

Setup

bun add next react react-dom drizzle-orm postgres @clerk/nextjs

Environment variables:

Apply the schema: bunx drizzle-kit push

Initialization

src/lib/db.ts Database client

import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

// Neon pooled endpoint = PgBouncer transaction mode → prepared statements off.
// ponytail: single module-level client; the serverless runtime + PgBouncer do
// the pooling, so no custom pool/globalThis singleton dance needed.
const client = postgres(process.env.DATABASE_URL!, { prepare: false });

export const db = drizzle({ client });

// ponytail: Clerk is hosted — set NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and
// CLERK_SECRET_KEY in the env. The publishable key is read client-side by
// <ClerkProvider>; the secret key is read server-side by clerkMiddleware().
// Both are picked up from the environment automatically — no wiring needed.

proxy.ts Route protection

// Clerk proxy for Next.js 16 (App Router) (Next 16 renamed middleware.ts → proxy.ts; clerkMiddleware is still the helper).
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";

// ponytail: guard the SaaS app surface; widen the matcher per app-type.
const isProtectedRoute = createRouteMatcher([
  "/dashboard(.*)",
  "/settings(.*)",
]);

export default clerkMiddleware(async (auth, request) => {
  // auth.protect() bounces logged-out users to Clerk's hosted sign-in.
  if (isProtectedRoute(request)) {
    await auth.protect();
  }
});

export const config = {
  // Clerk's documented matcher: skip Next internals + static files unless
  // referenced in search params, and always run on API/tRPC routes.
  matcher: [
    "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
    "/(api|trpc)(.*)",
  ],
};

app/layout.tsx Root layout / provider

// Root layout — <ClerkProvider> is required for Next.js 16 (App Router).
import { ClerkProvider } from "@clerk/nextjs";
import type { ReactNode } from "react";

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body>{children}</body>
      </html>
    </ClerkProvider>
  );
}

Newsletter schema: subscribers, lists, campaigns & per-subscriber sends

import { relations, sql } from "drizzle-orm";
import {
  check,
  index,
  pgTable,
  text,
  timestamp,
  unique,
  uuid,
} from "drizzle-orm/pg-core";
// Better Auth owns identity; we only reference its `user` table by id.
import { user } from "./auth-schema";

export type SubscriberStatus = "subscribed" | "unsubscribed" | "bounced";
export type CampaignStatus = "draft" | "scheduled" | "sent";
export type CampaignSendStatus =
  | "queued"
  | "delivered"
  | "opened"
  | "bounced";

/** An email address on someone's audience. Owned by the user who collected it;
 *  status walks the deliverability lifecycle. The owner+email unique stops the
 *  same address landing on one owner's audience twice. */
export const subscribers = pgTable(
  "subscribers",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    // Better Auth's user.id is text — match it, don't recast.
    ownerId: text("owner_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    email: text("email").notNull(),
    status: text("status")
      .$type<SubscriberStatus>()
      .notNull()
      .default("subscribed"),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    unique("subscribers_owner_email_unique").on(t.ownerId, t.email),
    index("idx_subscriber_owner").on(t.ownerId),
    check(
      "subscribers_status_check",
      sql`${t.status} in ('subscribed','unsubscribed','bounced')`,
    ),
  ],
);

/** A named segment of an owner's audience (e.g. "Weekly digest"). */
export const lists = pgTable(
  "lists",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    ownerId: text("owner_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    name: text("name").notNull(),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [index("idx_list_owner").on(t.ownerId)],
);

/** list <-> subscriber join. The composite unique is the membership identity —
 *  a subscriber is on a list at most once. */
export const listSubscriptions = pgTable(
  "list_subscriptions",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    listId: uuid("list_id")
      .notNull()
      .references(() => lists.id, { onDelete: "cascade" }),
    subscriberId: uuid("subscriber_id")
      .notNull()
      .references(() => subscribers.id, { onDelete: "cascade" }),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    unique("list_subscriptions_list_subscriber_unique").on(
      t.listId,
      t.subscriberId,
    ),
    index("idx_list_subscription_subscriber").on(t.subscriberId),
  ],
);

/** A broadcast targeting a list. status walks the lifecycle; scheduledAt is the
 *  send time once status is 'scheduled'. */
export const campaigns = pgTable(
  "campaigns",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    listId: uuid("list_id")
      .notNull()
      .references(() => lists.id, { onDelete: "cascade" }),
    subject: text("subject").notNull(),
    status: text("status")
      .$type<CampaignStatus>()
      .notNull()
      .default("draft"),
    scheduledAt: timestamp("scheduled_at", { withTimezone: true }),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    index("idx_campaign_list").on(t.listId),
    check(
      "campaigns_status_check",
      sql`${t.status} in ('draft','scheduled','sent')`,
    ),
  ],
);

/** One row per (campaign, subscriber) send attempt. status walks delivery; the
 *  campaign index drives the per-campaign delivery/open rollup. */
export const campaignSends = pgTable(
  "campaign_sends",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    campaignId: uuid("campaign_id")
      .notNull()
      .references(() => campaigns.id, { onDelete: "cascade" }),
    subscriberId: uuid("subscriber_id")
      .notNull()
      .references(() => subscribers.id, { onDelete: "cascade" }),
    status: text("status")
      .$type<CampaignSendStatus>()
      .notNull()
      .default("queued"),
    sentAt: timestamp("sent_at", { withTimezone: true }),
  },
  (t) => [
    // Drives the "delivery/open stats for this campaign" rollup query.
    index("idx_send_campaign").on(t.campaignId),
    check(
      "campaign_sends_status_check",
      sql`${t.status} in ('queued','delivered','opened','bounced')`,
    ),
  ],
);

export const subscribersRelations = relations(subscribers, ({ one, many }) => ({
  owner: one(user, { fields: [subscribers.ownerId], references: [user.id] }),
  listSubscriptions: many(listSubscriptions),
  sends: many(campaignSends),
}));

export const listsRelations = relations(lists, ({ one, many }) => ({
  owner: one(user, { fields: [lists.ownerId], references: [user.id] }),
  listSubscriptions: many(listSubscriptions),
  campaigns: many(campaigns),
}));

export const listSubscriptionsRelations = relations(
  listSubscriptions,
  ({ one }) => ({
    list: one(lists, {
      fields: [listSubscriptions.listId],
      references: [lists.id],
    }),
    subscriber: one(subscribers, {
      fields: [listSubscriptions.subscriberId],
      references: [subscribers.id],
    }),
  }),
);

export const campaignsRelations = relations(campaigns, ({ one, many }) => ({
  list: one(lists, {
    fields: [campaigns.listId],
    references: [lists.id],
  }),
  sends: many(campaignSends),
}));

export const campaignSendsRelations = relations(campaignSends, ({ one }) => ({
  campaign: one(campaigns, {
    fields: [campaignSends.campaignId],
    references: [campaigns.id],
  }),
  subscriber: one(subscribers, {
    fields: [campaignSends.subscriberId],
    references: [subscribers.id],
  }),
}));

Connection & security

## Next.js 16 ↔ Postgres pooling (Neon pooled endpoint, PgBouncer transaction mode)

Connect through Neon's **pooled** endpoint (`-pooler` host) via `DATABASE_URL`. Serverless
functions are short-lived and concurrent, so PgBouncer in **transaction mode** is what keeps
Postgres' connection ceiling from being blown.

### `prepare: false` is mandatory
Transaction-mode PgBouncer hands each transaction a different backend, so server-side prepared
statements (postgres-js' default) silently break across the pool. Disable them on the client:
`postgres(url, { prepare: false })`. This is also why **Drizzle, not Prisma**, is paired here —
Prisma's prepared-statement reliance is a blocked intersection on this endpoint.

### Connection reuse
- Construct the postgres-js client at **module scope** (`src/lib/db.ts`) so warm function
  instances reuse one socket instead of opening one per request.
- Cap the driver pool small — `max: 1` per instance. The shared pool lives in PgBouncer, not in
  your function; a large per-instance `max` just multiplies idle connections across instances.
- Keep `idle_timeout` ~20s and `connect_timeout` ~10s so frozen instances release backends fast.

### No session-level features
Transaction mode forbids anything that spans transactions on one backend: `LISTEN/NOTIFY`,
session-scoped `SET`, advisory-lock sessions, server-side cursors, and `WITH HOLD`. Need any of
those? Use Neon's **direct** (non-pooled) endpoint for that path only.

### Thresholds
- Drizzle/postgres-js: `prepare: false`, `max: 1`, `idle_timeout: 20`, `connect_timeout: 10`.
- Neon Free pooled budget is ~10k client connections; keep concurrency well under the project's
  `max_connections` (often 100–900 by plan) by leaning on PgBouncer, never on driver pooling.

Verified identity sync (Clerk)

Clerk users sync into a local user table idempotently — duplicate, out-of-order, and concurrent webhooks converge to one correct row, so your foreign keys resolve. Replayed against a live database, not just type-checked.

Local user table

import { pgTable, text, timestamp } from "drizzle-orm/pg-core";

// Local mirror of Clerk identity — the FK target app-type schemas reference as user.
// id = Clerk's user id, so existing user_id foreign keys resolve once the sync runs.
export const user = pgTable("user", {
  id: text("id").primaryKey(), // = Clerk user id
  email: text("email"),
  firstName: text("first_name"),
  lastName: text("last_name"),
  imageUrl: text("image_url"),
  updatedAt: timestamp("updated_at", { withTimezone: true }), // staleness key (Clerk updated_at)
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

src/lib/identity/record.ts

import { and, eq, isNull, lt, or } from "drizzle-orm";
import { user } from "./schema";

export type ClerkUserEvent = {
  type: string;
  data: {
    id: string;
    email_addresses?: { email_address: string }[];
    first_name?: string | null;
    last_name?: string | null;
    image_url?: string | null;
    updated_at?: number;
  };
};

// Idempotent + concurrency-safe sync of a Clerk user into the local user table.
// Keyed on id (= Clerk id, the PK); the staleness guard lives in the UPDATE WHERE so
// a late/older event cannot clobber newer state. user.deleted removes the row.
export async function recordClerkEvent(
  // ponytail: loosely typed Drizzle client so the emitted core stays portable.
  db: any,
  event: ClerkUserEvent,
): Promise<{ changed: boolean }> {
  const d = event.data;
  if (event.type === "user.deleted") {
    const deleted = await db.delete(user).where(eq(user.id, d.id)).returning({ id: user.id });
    return { changed: deleted.length > 0 };
  }

  const email = d.email_addresses?.[0]?.email_address ?? null;
  const eventAt = new Date(d.updated_at ?? 0);
  const fields = {
    email,
    firstName: d.first_name ?? null,
    lastName: d.last_name ?? null,
    imageUrl: d.image_url ?? null,
    updatedAt: eventAt,
  };

  const updated = await db
    .update(user)
    .set(fields)
    .where(and(eq(user.id, d.id), or(isNull(user.updatedAt), lt(user.updatedAt, eventAt))))
    .returning({ id: user.id });
  if (updated.length > 0) return { changed: true };

  const [existing] = await db.select({ id: user.id }).from(user).where(eq(user.id, d.id)).limit(1);
  if (existing) return { changed: false };

  const inserted = await db
    .insert(user)
    .values({ id: d.id, ...fields })
    .onConflictDoNothing({ target: user.id })
    .returning({ id: user.id });
  return { changed: inserted.length > 0 };
}

app/api/webhooks/clerk/route.ts

// Clerk identity webhook for Next.js 16 (App Router). Clerk webhooks are svix — verify the
// signature, then hand the event to the idempotent recordClerkEvent.
import { Webhook } from "svix";
import { db } from "@/lib/db";
import { recordClerkEvent, type ClerkUserEvent } from "@/lib/identity/record";

const USER_EVENTS = new Set(["user.created", "user.updated", "user.deleted"]);

export async function POST(request: Request): Promise<Response> {
  const secret = process.env.CLERK_WEBHOOK_SECRET;
  if (!secret) return Response.json({ error: "Server misconfigured" }, { status: 500 });

  const raw = await request.text();
  const headers = Object.fromEntries(request.headers.entries());

  let event: ClerkUserEvent;
  try {
    event = new Webhook(secret).verify(raw, headers) as ClerkUserEvent;
  } catch {
    return Response.json({ error: "Invalid signature" }, { status: 403 });
  }

  if (USER_EVENTS.has(event.type)) await recordClerkEvent(db, event);
  return Response.json({ ok: true });
}

Decisions & compatibility

Related stacks