codexmachina
registry/nuxt-postgres-better-auth-crm

CRM on Nuxt 4, Postgres (Neon) and Better Auth

verified 2026-07-15nuxt4.4.8postgres3.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
Nuxt 4routing + 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

Nuxt 4

Nuxt 4 (framework mode) — full-stack Vue SSR: client under app/ (Vite), server under server/ (Nitro), API as server/api/*.post.ts Nitro route handlers.

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).

CRM

Sales CRM — companies, contacts, named pipelines, deal-stage tracking, and an append-only activity trail.

Setup

bun add nuxt vue 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

server/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, Nuxt 4).
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
// Reuse the SAME postgres-js/Drizzle client the db slice exported in
// server/lib/db.ts — Better Auth shares the pooled `DATABASE_URL` connection.
// Server code lives under server/ (Nitro); reach it via Nuxt's `~~` rootDir alias.
import { db } from "~~/server/lib/db";
import * as authSchema from "~~/server/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;
// Better Auth mounted as a Nitro catch-all route. auth.handler is framework-
// agnostic — (Request) => Promise<Response> — and toWebRequest adapts the H3
// event into a web Request. The `[...all]` param catches every /api/auth/*
// sub-path Better Auth routes internally.
// ponytail: Nuxt AUTO-IMPORTS defineEventHandler/toWebRequest from Nitro/H3 at
// runtime; we import them EXPLICITLY from h3 (Nitro's engine, a nuxt dep) so this
// handler type-checks under standalone tsc — the one idiom trade for verifiability.
import { defineEventHandler, toWebRequest } from "h3";
import { auth } from "~~/lib/auth";

export default defineEventHandler((event) => auth.handler(toWebRequest(event)));
// Vue/Nuxt client binding. better-auth/vue exposes the framework-appropriate client
// (signIn / signUp / useSession as Vue refs) — the Vue analog of the React client.
import { createAuthClient } from "better-auth/vue";

export const authClient = createAuthClient();
// Session gate — the Nuxt analog of Next's Edge proxy / RR8's requireAuth. A Nitro
// server middleware runs on every SSR/API request; guard the app surface and do the
// REAL server-side session check (like RR8 — stronger than Next's cookie-existence peek).
// ponytail: this guards SSR loads + direct hits + API — the true security boundary. For
// client-side SPA navigation add an app/middleware/*.ts route middleware (authored with
// Nuxt auto-imports; NOT tsc-gated, since plain tsc can't resolve them — spec §6).
// Explicit h3 imports so this type-checks standalone (see the handler note above).
import { defineEventHandler, getRequestURL, sendRedirect, toWebRequest } from "h3";
import { auth } from "~~/lib/auth";

export default defineEventHandler(async (event) => {
  const { pathname } = getRequestURL(event);
  // ponytail: guard the SaaS app surface; widen the prefixes per app-type.
  if (!pathname.startsWith("/dashboard") && !pathname.startsWith("/settings")) return;
  const session = await auth.api.getSession({ headers: toWebRequest(event).headers });
  if (!session) return sendRedirect(event, "/sign-in", 302);
});

Sales CRM schema: companies, contacts, pipelines, deals & activities

Companies & contacts

account records (companies de-duped on domain) and the contacts that belong to them, with nullable company FK to support unattached leads

Pipelines & deals

named sales pipelines and the opportunities (deals) moving through them, each carrying a contact FK, an owner FK to Better Auth user, and amount stored as integer cents

Deal stage tracking

stage column on deals enforced by CHECK constraint with an index that powers pipeline-board grouping by stage

Activity interaction log

append-only calls/emails/notes logged against a deal by a Better Auth actor, forming the per-deal audit timeline

src/db/schema.ts
// === file: server/db/schema.ts ===
import { relations, sql } from "drizzle-orm";
import {
  bigint,
  check,
  index,
  pgTable,
  text,
  timestamp,
  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 DealStage = "lead" | "qualified" | "won" | "lost";
export type ActivityType = "call" | "email" | "note";

/** Account record: the organization a set of contacts belongs to. */
export const companies = pgTable(
  "companies",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    name: text("name").notNull(),
    // Primary web domain — opaque key we de-dupe accounts on.
    domain: text("domain"),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [index("idx_company_domain").on(t.domain)],
);

/** A person we sell to. Company is nullable (a lead can exist before we know
 *  their employer); owner is the Better Auth user accountable for the contact. */
export const contacts = pgTable(
  "contacts",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    // Nullable: unattached leads get a company once qualified.
    companyId: uuid("company_id").references(() => companies.id, {
      onDelete: "set null",
    }),
    email: text("email").notNull(),
    name: text("name"),
    // Better Auth's user.id is text — match it, don't recast.
    ownerId: text("owner_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    index("idx_contact_company").on(t.companyId),
    index("idx_contact_owner").on(t.ownerId),
  ],
);

/** Named sales pipeline deals move through (e.g. Inbound, Enterprise). */
export const pipelines = pgTable("pipelines", {
  id: uuid("id").primaryKey().defaultRandom(),
  name: text("name").notNull(),
  createdAt: timestamp("created_at", { withTimezone: true })
    .notNull()
    .defaultNow(),
});

/** An open/closed opportunity against a contact, in a pipeline, owned by a
 *  Better Auth user. amount in integer cents (no float money). */
export const deals = pgTable(
  "deals",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    contactId: uuid("contact_id")
      .notNull()
      .references(() => contacts.id, { onDelete: "cascade" }),
    pipelineId: uuid("pipeline_id")
      .notNull()
      .references(() => pipelines.id),
    ownerId: text("owner_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    title: text("title").notNull(),
    // ponytail: money as integer cents — no float, no numeric type churn.
    amountCents: bigint("amount_cents", { mode: "number" })
      .notNull()
      .default(0),
    stage: text("stage").$type<DealStage>().notNull().default("lead"),
    closeDate: timestamp("close_date", { withTimezone: true }),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    // Drives the pipeline board "deals grouped by stage" query.
    index("idx_deal_stage").on(t.stage),
    index("idx_deal_contact").on(t.contactId),
    check(
      "deals_stage_check",
      sql`${t.stage} in ('lead','qualified','won','lost')`,
    ),
  ],
);

/** Append-only interaction trail against a deal, logged by a Better Auth user. */
export const activities = pgTable(
  "activities",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    dealId: uuid("deal_id")
      .notNull()
      .references(() => deals.id, { onDelete: "cascade" }),
    // Who logged the interaction.
    actorId: text("actor_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    type: text("type").$type<ActivityType>().notNull().default("note"),
    body: text("body"),
    occurredAt: timestamp("occurred_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    // Drives the per-deal timeline (most-recent-first).
    index("idx_activity_deal_time").on(t.dealId, t.occurredAt),
    check(
      "activities_type_check",
      sql`${t.type} in ('call','email','note')`,
    ),
  ],
);

export const companiesRelations = relations(companies, ({ many }) => ({
  contacts: many(contacts),
}));

export const contactsRelations = relations(contacts, ({ one, many }) => ({
  company: one(companies, {
    fields: [contacts.companyId],
    references: [companies.id],
  }),
  owner: one(user, { fields: [contacts.ownerId], references: [user.id] }),
  deals: many(deals),
}));

export const pipelinesRelations = relations(pipelines, ({ many }) => ({
  deals: many(deals),
}));

export const dealsRelations = relations(deals, ({ one, many }) => ({
  contact: one(contacts, {
    fields: [deals.contactId],
    references: [contacts.id],
  }),
  pipeline: one(pipelines, {
    fields: [deals.pipelineId],
    references: [pipelines.id],
  }),
  owner: one(user, { fields: [deals.ownerId], references: [user.id] }),
  activities: many(activities),
}));

export const activitiesRelations = relations(activities, ({ one }) => ({
  deal: one(deals, { fields: [activities.dealId], references: [deals.id] }),
  actor: one(user, { fields: [activities.actorId], references: [user.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

Client/server split: the DB client, Drizzle schema, records, and webhooks are server-side (server/). The `@/` alias is the client root (app/); server code reaches shared modules via Nuxt's `~~` rootDir alias (e.g. `~~/server/db/schema`).

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

Deal stage is text + CHECK ('lead','qualified','won','lost') on the deals table — new stages ship without an ALTER TYPE migration; the idx_deal_stage index drives the pipeline board's 'deals grouped by stage' query.

note

activities is append-only (no updates, no deletes cascaded from deal): the per-deal timeline is always a raw log, never a mutated summary, queried via idx_activity_deal_time (deal_id, occurred_at).