codexmachina
registry/nextjs-postgres-clerk-saas

SaaS on Next.js 16 (App Router), Postgres (Neon) and Clerk

verified 2026-07-15next16.2.9postgres3.4.9@clerk/nextjs7.5.15@polar-sh/sdk0.48.1@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
Clerksession
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.

Clerk

Clerk — hosted identity (sign-in UI, sessions, user management) mounted via middleware + provider.

SaaS

Multi-tenant SaaS — organizations, role-based memberships, plans/subscriptions, and credit metering.

Setup

bun add next react react-dom drizzle-orm postgres @clerk/nextjs
DATABASE_URLNeon pooled (-pooler) connection string
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
CLERK_SECRET_KEY
CLERK_WEBHOOK_SECRETsvix secret that verifies Clerk webhook signatures

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 });

// 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.
// 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)(.*)",
  ],
};
// 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>
  );
}

Multi-tenant SaaS schema: organizations, billing & usage metering

Organizations & multi-tenancy

the tenant boundary every billable and metered row hangs off

Memberships & role-based access

org↔user join carrying owner/admin/member roles, unique per pair

Plans & subscription billing tables

the billable plan catalog and each org's current subscription state

API usage & credit/token metering

append-only usage rows that drive quota checks and usage billing

src/db/schema.ts
// === file: src/db/schema.ts ===
import { relations, sql } from "drizzle-orm";
import {
  bigint,
  check,
  index,
  integer,
  pgTable,
  text,
  timestamp,
  unique,
  uuid,
} from "drizzle-orm/pg-core";
// Better Auth owns identity; we only reference its `user` table by id. With Clerk
// the identity-sync mirror occupies this same ./auth-schema slot (src/db/auth-schema.ts).
import { user } from "./auth-schema";

export type MemberRole = "owner" | "admin" | "member";
export type SubscriptionStatus =
  | "trialing"
  | "active"
  | "past_due"
  | "canceled";

/** Tenant boundary: every billable/metered row hangs off an organization. */
export const organizations = pgTable(
  "organizations",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    slug: text("slug").notNull().unique(),
    name: text("name").notNull(),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [index("idx_org_slug").on(t.slug)],
);

/** org <-> user join with role. The composite unique is the membership identity. */
export const memberships = pgTable(
  "memberships",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    organizationId: uuid("organization_id")
      .notNull()
      .references(() => organizations.id, { onDelete: "cascade" }),
    // Better Auth's user.id is text — match it, don't recast.
    userId: text("user_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    role: text("role").$type<MemberRole>().notNull().default("member"),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    unique("memberships_org_user_unique").on(t.organizationId, t.userId),
    index("idx_membership_user").on(t.userId),
    check(
      "memberships_role_check",
      sql`${t.role} in ('owner','admin','member')`,
    ),
  ],
);

/** Catalog of billable plans (seed-managed). priceCents keeps money integer. */
export const plans = pgTable("plans", {
  id: uuid("id").primaryKey().defaultRandom(),
  slug: text("slug").notNull().unique(), // free | pro | scale
  name: text("name").notNull(),
  priceCents: integer("price_cents").notNull().default(0),
  // Monthly included credits; metering checks usage against this.
  monthlyCredits: bigint("monthly_credits", { mode: "number" })
    .notNull()
    .default(0),
});

/** One active subscription per org. Mirrors the billing provider's state. */
export const subscriptions = pgTable(
  "subscriptions",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    organizationId: uuid("organization_id")
      .notNull()
      .references(() => organizations.id, { onDelete: "cascade" }),
    planId: uuid("plan_id")
      .notNull()
      .references(() => plans.id),
    status: text("status")
      .$type<SubscriptionStatus>()
      .notNull()
      .default("trialing"),
    // The generic billing-sync contract every webhook provider (Polar/Stripe/LemonSqueezy)
    // needs: the provider's subscription id as a UNIQUE idempotency key, plus the last
    // event timestamp as an out-of-order/staleness guard. The billing fragment maps a
    // provider's events onto these — it does NOT redeclare this table (compose, not duplicate).
    providerSubId: text("provider_sub_id").unique(),
    providerEventAt: timestamp("provider_event_at", { withTimezone: true }),
    currentPeriodEnd: timestamp("current_period_end", { withTimezone: true }),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    // One live subscription per org (the metering layer reads exactly one).
    unique("subscriptions_org_unique").on(t.organizationId),
    index("idx_sub_status").on(t.status),
    check(
      "subscriptions_status_check",
      sql`${t.status} in ('trialing','active','past_due','canceled')`,
    ),
  ],
);

/** Append-only credit/token meter. Roll up by org+window for quota + billing. */
export const apiUsage = pgTable(
  "api_usage",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    organizationId: uuid("organization_id")
      .notNull()
      .references(() => organizations.id, { onDelete: "cascade" }),
    // Who/what spent — opaque key id, nullable for org-level system calls.
    apiKeyId: text("api_key_id"),
    creditsUsed: bigint("credits_used", { mode: "number" }).notNull(),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    // Drives the "credits used this period" rollup query.
    index("idx_usage_org_time").on(t.organizationId, t.createdAt),
  ],
);

export const organizationsRelations = relations(organizations, ({ many }) => ({
  memberships: many(memberships),
  subscriptions: many(subscriptions),
  usage: many(apiUsage),
}));

export const membershipsRelations = relations(memberships, ({ one }) => ({
  organization: one(organizations, {
    fields: [memberships.organizationId],
    references: [organizations.id],
  }),
  user: one(user, { fields: [memberships.userId], references: [user.id] }),
}));

export const subscriptionsRelations = relations(subscriptions, ({ one }) => ({
  organization: one(organizations, {
    fields: [subscriptions.organizationId],
    references: [organizations.id],
  }),
  plan: one(plans, {
    fields: [subscriptions.planId],
    references: [plans.id],
  }),
}));

export const apiUsageRelations = relations(apiUsage, ({ one }) => ({
  organization: one(organizations, {
    fields: [apiUsage.organizationId],
    references: [organizations.id],
  }),
}));

Verified billing (Polar)

✓ Idempotency proven: a duplicate webhook delivery yields one subscription, not two; a stale, out-of-order event can't overwrite newer state. Replayed against a live database, not just type-checked.
src/lib/billing/record.ts
import { and, eq, isNull, lt, or } from "drizzle-orm";
import { subscriptions } from "@/db/schema";

export type PolarSubscriptionEvent = {
  type: string;
  data: {
    id: string;
    status: string;
    currentPeriodEnd: string | null;
    modifiedAt: string;
    metadata: { organizationId: string; planId: string };
  };
};

const STATUS_MAP: Record<string, "trialing" | "active" | "past_due" | "canceled"> = {
  trialing: "trialing",
  active: "active",
  past_due: "past_due",
  unpaid: "past_due",
  canceled: "canceled",
  revoked: "canceled",
};

// Idempotent + CONCURRENCY-safe sync of a Polar subscription. The staleness guard
// lives in the UPDATE's WHERE clause, so Postgres' row lock serializes concurrent
// retries (a stale/older event matches no row); brand-new rows insert with
// onConflictDoNothing (race-safe). Returns changed=true only on a real advance, so
// callers can guard side effects (emails) against Polar's duplicate deliveries.
export async function recordPolarEvent(
  // ponytail: loosely typed Drizzle client so the emitted core stays portable
  // across the app's exact client type.
  db: any,
  event: PolarSubscriptionEvent,
): Promise<{ changed: boolean }> {
  const sub = event.data;
  const status = STATUS_MAP[sub.status];
  if (!status) return { changed: false }; // unknown status — ignore, don't default
  const eventAt = new Date(sub.modifiedAt);
  const currentPeriodEnd = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;

  // Guarded UPDATE: applies only when our event is strictly newer than what's stored.
  // No TOCTOU — the comparison is in the WHERE, evaluated under the row lock.
  const updated = await db
    .update(subscriptions)
    .set({ status, currentPeriodEnd, providerEventAt: eventAt })
    .where(
      and(
        eq(subscriptions.providerSubId, sub.id),
        or(isNull(subscriptions.providerEventAt), lt(subscriptions.providerEventAt, eventAt)),
      ),
    )
    .returning({ id: subscriptions.id });
  if (updated.length > 0) return { changed: true };

  // No row updated: the row exists but our event is stale (guard rejected it), or it
  // doesn't exist yet. If it exists, this is a stale/duplicate delivery — ignore.
  const [existing] = await db
    .select({ id: subscriptions.id })
    .from(subscriptions)
    .where(eq(subscriptions.providerSubId, sub.id))
    .limit(1);
  if (existing) return { changed: false };

  const inserted = await db
    .insert(subscriptions)
    .values({
      organizationId: sub.metadata.organizationId,
      planId: sub.metadata.planId,
      status,
      currentPeriodEnd,
      providerSubId: sub.id,
      providerEventAt: eventAt,
    })
    .onConflictDoNothing({ target: subscriptions.providerSubId })
    .returning({ id: subscriptions.id });

  return { changed: inserted.length > 0 };
}
// Polar webhook for Next.js 16 (App Router). Verifies the HMAC signature, then hands the
// event to the idempotent recordPolarEvent. Returns 200 even if non-critical work
// fails; only a signature failure is rejected.
import { validateEvent, WebhookVerificationError } from "@polar-sh/sdk/webhooks";
import { db } from "@/lib/db";
import { recordPolarEvent, type PolarSubscriptionEvent } from "@/lib/billing/record";

const SUBSCRIPTION_EVENTS = new Set([
  "subscription.created",
  "subscription.active",
  "subscription.updated",
  "subscription.canceled",
  "subscription.revoked",
  "subscription.uncanceled",
]);

export async function POST(request: Request): Promise<Response> {
  const secret = process.env.POLAR_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: { type: string; data: unknown };
  try {
    event = validateEvent(raw, headers, secret);
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return Response.json({ error: "Invalid signature" }, { status: 403 });
    }
    throw error;
  }

  if (SUBSCRIPTION_EVENTS.has(event.type)) {
    await recordPolarEvent(db, event as PolarSubscriptionEvent);
  }
  return Response.json({ ok: true });
}

Verified tenant isolation

✓ Proven at the database: with the tenant context set to one org, queries return zero of another org's rows (Postgres RLS, FORCEd so even the table owner is bound).
migrations/rls.sql
-- IMPORTANT: RLS is bypassed by the table owner (unless FORCEd) and ALWAYS by
-- BYPASSRLS / superuser roles. Neon's default neondb_owner role has BYPASSRLS, so
-- connecting your app as neondb_owner makes RLS SILENTLY INERT. Create a dedicated
-- non-BYPASSRLS role and point your app's DATABASE_URL at it:
DO $$ BEGIN
  IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'app_tenant') THEN
    CREATE ROLE app_tenant NOLOGIN NOBYPASSRLS;
  END IF;
END $$;
-- Grant LOGIN + a password OUT OF BAND (never in committed SQL), then point DATABASE_URL at it:
--   ALTER ROLE app_tenant LOGIN PASSWORD '<generated>';
GRANT USAGE ON SCHEMA public TO app_tenant;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_tenant;
-- Future tables stay covered (the one-time GRANT above only sees today's tables):
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_tenant;

-- Tenant isolation via Row-Level Security (FORCE = even the table owner is bound). The shared `plans` catalog is intentionally left open.

ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE organizations FORCE ROW LEVEL SECURITY;
CREATE POLICY organizations_tenant_isolation ON organizations
  USING (id = current_setting('app.current_org_id', true)::uuid)
  WITH CHECK (id = current_setting('app.current_org_id', true)::uuid);

ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE memberships FORCE ROW LEVEL SECURITY;
CREATE POLICY memberships_tenant_isolation ON memberships
  USING (organization_id = current_setting('app.current_org_id', true)::uuid)
  WITH CHECK (organization_id = current_setting('app.current_org_id', true)::uuid);

ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE subscriptions FORCE ROW LEVEL SECURITY;
CREATE POLICY subscriptions_tenant_isolation ON subscriptions
  USING (organization_id = current_setting('app.current_org_id', true)::uuid)
  WITH CHECK (organization_id = current_setting('app.current_org_id', true)::uuid);

ALTER TABLE api_usage ENABLE ROW LEVEL SECURITY;
ALTER TABLE api_usage FORCE ROW LEVEL SECURITY;
CREATE POLICY api_usage_tenant_isolation ON api_usage
  USING (organization_id = current_setting('app.current_org_id', true)::uuid)
  WITH CHECK (organization_id = current_setting('app.current_org_id', true)::uuid);
import { sql } from "drizzle-orm";

// Run queries with the tenant context set for THIS transaction. RLS policies read
// app.current_org_id; unset => fail-closed (no rows). Wrap every request's data
// access in withTenant(db, session.orgId, (tx) => ...).
export async function withTenant<T>(
  db: { transaction: (fn: (tx: any) => Promise<T>) => Promise<T> },
  orgId: string,
  fn: (tx: any) => Promise<T>,
): Promise<T> {
  return db.transaction(async (tx) => {
    await tx.execute(sql`select set_config('app.current_org_id', ${orgId}, true)`);
    return fn(tx);
  });
}

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. Replayed against a live database.
src/db/auth-schema.ts
// === file: src/db/auth-schema.ts ===
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.
// This IS the auth-schema slot for Clerk cells: the SaaS schema's ./auth-schema FK
// target (src/db/auth-schema.ts) resolves here, same slot Better Auth's generated file fills.
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(),
});
import { and, eq, isNull, lt, or } from "drizzle-orm";
import { user } from "@/db/auth-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 };
}
// 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 });
}

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

Hosted: Clerk owns identity and does NOT create a local `user` table. Store `clerk_user_id` as text without a foreign key, or sync Clerk users into a local table via webhook before relying on FKs to `user`.

note

One active subscription per organization (unique on organization_id) — the metering layer reads exactly one.

note

Usage is an append-only meter (api_usage): roll up by organization + time window for quota and billing rather than mutating a running total.

caveat

Clerk is a hosted identity provider and does not create a local `user` table. This schema's foreign keys to `user` assume a local identity table (as Better Auth provides). With Clerk, store `clerk_user_id` as a text column without a foreign key, or sync Clerk users into a local `users` table via webhook before relying on these FKs.