codexmachina
registry/nextjs-postgres-better-auth-newsletter

Newsletter platform on Next.js 16 (App Router), Postgres (Neon) and Better Auth

verified 2026-07-15next16.2.9postgres3.4.9better-auth1.6.23@neondatabase/serverless1.1.0

Type-checked against the real SDKs, migration applied to a live Postgres (Neon), connection clients load-tested, then tracked for upstream drift and re-verified when it moves. How we verify

request path
Browserrequest
fetch
Next.js 16 (App Router)routing + proxy
verify
Better Authsession
query
Postgres (Neon)pooled

session validation runs in server components and route handlers, not at the edge

What you're getting

Next.js 16 (App Router)

Next.js 16 App Router — file-based routing, server components, and the Edge proxy (Next 16's renamed middleware).

Postgres (Neon)

Postgres on Neon via Drizzle ORM and the postgres-js driver.

Better Auth

Better Auth — self-hosted auth running inside your app against your Postgres (Drizzle adapter).

Newsletter platform

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

Setup

bun add next react react-dom drizzle-orm postgres better-auth
DATABASE_URLNeon pooled (-pooler) connection string
BETTER_AUTH_SECRETgenerate with `openssl rand -base64 32`
BETTER_AUTH_URLyour app's base URL

Apply the schema with bunx drizzle-kit push

Initialization

Database client

src/lib/db.ts
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 });
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";

export const user = pgTable("user", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  emailVerified: boolean("email_verified").notNull().default(false),
  image: text("image"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const session = pgTable("session", {
  id: text("id").primaryKey(),
  expiresAt: timestamp("expires_at").notNull(),
  token: text("token").notNull().unique(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull(),
  ipAddress: text("ip_address"),
  userAgent: text("user_agent"),
  userId: text("user_id")
    .notNull()
    .references(() => user.id, { onDelete: "cascade" }),
});

export const account = pgTable("account", {
  id: text("id").primaryKey(),
  accountId: text("account_id").notNull(),
  providerId: text("provider_id").notNull(),
  userId: text("user_id")
    .notNull()
    .references(() => user.id, { onDelete: "cascade" }),
  accessToken: text("access_token"),
  refreshToken: text("refresh_token"),
  idToken: text("id_token"),
  accessTokenExpiresAt: timestamp("access_token_expires_at"),
  refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
  scope: text("scope"),
  password: text("password"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull(),
});

export const verification = pgTable("verification", {
  id: text("id").primaryKey(),
  identifier: text("identifier").notNull(),
  value: text("value").notNull(),
  expiresAt: timestamp("expires_at").notNull(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
// Better Auth instance (self-hosted, Next.js 16 (App Router)).
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
// Reuse the SAME postgres-js/Drizzle client the db slice exported in
// src/lib/db.ts — Better Auth shares the pooled `DATABASE_URL` connection.
import { db } from "./db";
import * as authSchema from "@/db/auth-schema";

export const auth = betterAuth({
  // Drizzle adapter over the shared client. The schema is passed HERE (not into
  // drizzle()) — it is the only consumer that needs it, so the db client stays
  // schema-less. ponytail: do NOT enable experimental.joins; it is the one option
  // that would make the adapter reach into db._.fullSchema.
  // ponytail: usePlural stays false (the default) — with it true Better Auth would
  // look for `sessions`/`accounts` and could bind to the analytics/ledger app-type tables.
  database: drizzleAdapter(db, { provider: "pg", schema: authSchema }),
  // ponytail: email+password is the shortest real auth that works out of
  // the box — add socialProviders / plugins here when the app needs them.
  emailAndPassword: { enabled: true },
  secret: process.env.BETTER_AUTH_SECRET,
  baseURL: process.env.BETTER_AUTH_URL,
});

export type Session = typeof auth.$Infer.Session;
// Mount Better Auth on Next's route layer.
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";

export const { GET, POST } = toNextJsHandler(auth);
// Session-protection proxy for Next.js 16 (App Router) (Next 16 renamed middleware.ts → proxy.ts).
// ponytail: getSessionCookie only checks the cookie EXISTS (no DB hit at
// the Edge runtime) — do the real auth.api.getSession() check inside
// protected Server Components / route handlers. This just bounces
// logged-out users before render.
import { NextResponse, type NextRequest } from "next/server";
import { getSessionCookie } from "better-auth/cookies";

export function proxy(request: NextRequest) {
  const sessionCookie = getSessionCookie(request);
  if (!sessionCookie) {
    return NextResponse.redirect(new URL("/sign-in", request.url));
  }
  return NextResponse.next();
}

export const config = {
  // ponytail: guard the SaaS app surface; widen the matcher per app-type.
  matcher: ["/dashboard/:path*", "/settings/:path*"],
};

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

Subscribers & deliverability status

email addresses collected by an owner, with a status CHECK walking subscribed/unsubscribed/bounced

Lists & list_subscriptions membership

named audience segments and the join table that places a subscriber on a list at most once

Campaigns & scheduling

broadcasts targeting a list, with a status CHECK gating draft/scheduled/sent and a nullable scheduledAt timestamp

Campaign_sends & delivery tracking

one append-only row per (campaign, subscriber) tracking the queued→delivered→opened→bounced lifecycle

src/db/schema.ts
// === file: src/db/schema.ts ===
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],
  }),
}));

Deploy targets

✓ The right DB client for where you deploy: load-tested with concurrent queries against a live database. Edge needs the HTTP driver (no TCP); serverless needs a tiny pool.
src/lib/db.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

// Serverless: one connection per (short-lived) instance; Neon's pooler multiplexes.
export const sql = postgres(process.env.DATABASE_URL!, { prepare: false, max: 1 });
export const db = drizzle(sql);
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

// Long-running process: a real, reused pool. Still prepare:false on the pooled endpoint.
export const sql = postgres(process.env.DATABASE_URL!, { prepare: false, max: 10, idle_timeout: 20 });
export const db = drizzle(sql);
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";

// Edge/Workers have NO TCP sockets, so postgres-js cannot run here. Neon's HTTP
// driver speaks Postgres over fetch — the only client that works on Workers.
export const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);

Decisions and compatibility

note

Auth runs in proxy.ts (Next 16's renamed middleware) on the Edge runtime: it gates on the session cookie's presence only — full session validation happens in Server Components and route handlers, not in the proxy.

note

prepare: false is mandatory — Neon's pooled endpoint is PgBouncer in transaction mode, where server-side prepared statements break across the pool.

note

Drizzle is paired here (not Prisma): Prisma's prepared-statement reliance is incompatible with transaction-mode pooling.

note

Self-hosted: Better Auth owns the user/session/account/verification tables. This stack emits them (db/auth-schema.ts) and hands them to the Drizzle adapter, so app-type schemas can foreign-key `user` directly.

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 is append-only per (campaign, subscriber): each row walks 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.