Neon separates PostgreSQL storage from compute — compute scales to zero when idle and warms in under a second. The @neondatabase/serverless driver uses HTTP and WebSockets instead of TCP, enabling PostgreSQL queries from Cloudflare Workers, Vercel Edge Functions, and any environment where Node.js TCP connections aren’t available. Database branching creates instant copy-on-write snapshots — each pull request gets its own branch with production data, automatically deprovisioned after merge. Autoscaling adjusts vCPUs and RAM dynamically. Drizzle ORM works with Neon’s serverless driver identically to standard PostgreSQL. Claude Code generates Neon connection configs, serverless edge queries, branch provisioning scripts, Drizzle schemas, and the autoscaling configurations for production PostgreSQL applications.
CLAUDE.md for Neon Projects
## Neon Stack
- Driver: @neondatabase/serverless >= 0.10 for edge/serverless; pg for long-running Node.js
- ORM: drizzle-orm >= 0.34 with drizzle-orm/neon-http (serverless) or drizzle-orm/pg-core (node)
- Branching: neon branches create/delete via Neon API for CI/CD preview envs
- Pooling: use pooled connection string (port 5432 → 6543 PgBouncer) for serverless
- Autoscale: set min/max compute units in console or API — 0.25 CU min = scale to zero
- Migrations: drizzle-kit push (dev) or drizzle-kit generate + drizzle-kit migrate (prod)
Serverless/Edge Connection
// lib/db.ts — Neon serverless driver setup
import { neon, neonConfig } from "@neondatabase/serverless"
import { drizzle } from "drizzle-orm/neon-http"
import * as schema from "./schema"
// Enable WebSocket pooling for repeated queries in same invocation
neonConfig.fetchConnectionCache = true
// HTTP-based client — works in Cloudflare Workers, Vercel Edge, etc.
const sql = neon(process.env.DATABASE_URL!)
export const db = drizzle(sql, { schema })
// Direct SQL for complex queries
export { sql }
// lib/db-node.ts — standard driver for Node.js / long-running servers
import { Pool } from "@neondatabase/serverless"
import { drizzle } from "drizzle-orm/neon-serverless"
import ws from "ws"
import * as schema from "./schema"
import { neonConfig } from "@neondatabase/serverless"
// Required for WebSocket in Node.js environments
neonConfig.webSocketConstructor = ws
// Use pooled connection string for PgBouncer
const pool = new Pool({ connectionString: process.env.DATABASE_URL_POOLED! })
export const db = drizzle(pool, { schema })
Drizzle Schema
// lib/schema.ts — Drizzle PostgreSQL schema for Neon
import {
pgTable, pgEnum, text, integer, timestamp, boolean,
index, uniqueIndex, uuid, jsonb
} from "drizzle-orm/pg-core"
import { relations } from "drizzle-orm"
export const orderStatusEnum = pgEnum("order_status", [
"pending", "processing", "shipped", "delivered", "cancelled"
])
export const customers = pgTable("customers", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull(),
name: text("name").notNull(),
stripeCustomerId: text("stripe_customer_id"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
emailIdx: uniqueIndex("customers_email_idx").on(table.email),
stripeIdx: index("customers_stripe_idx").on(table.stripeCustomerId),
}))
export const orders = pgTable("orders", {
id: uuid("id").primaryKey().defaultRandom(),
customerId: uuid("customer_id")
.references(() => customers.id, { onDelete: "restrict" })
.notNull(),
status: orderStatusEnum("status").notNull().default("pending"),
totalCents: integer("total_cents").notNull(),
items: jsonb("items").$type<OrderItem[]>().notNull(),
shippingAddress: jsonb("shipping_address").$type<Address>(),
trackingNumber: text("tracking_number"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
customerIdx: index("orders_customer_idx").on(table.customerId),
statusIdx: index("orders_status_idx").on(table.status),
createdAtIdx: index("orders_created_at_idx").on(table.createdAt),
}))
export const ordersRelations = relations(orders, ({ one }) => ({
customer: one(customers, {
fields: [orders.customerId],
references: [customers.id],
}),
}))
Queries and Mutations
// lib/queries.ts — type-safe Drizzle queries
import { db, sql } from "./db"
import { orders, customers } from "./schema"
import { eq, desc, and, gte, lte, count, sum, avg } from "drizzle-orm"
export async function getOrderWithCustomer(orderId: string) {
return await db.query.orders.findFirst({
where: eq(orders.id, orderId),
with: { customer: true },
})
}
export async function listCustomerOrders(
customerId: string,
opts: { limit?: number; offset?: number; status?: string } = {}
) {
const { limit = 20, offset = 0, status } = opts
return await db
.select()
.from(orders)
.where(
and(
eq(orders.customerId, customerId),
status ? eq(orders.status, status as any) : undefined
)
)
.orderBy(desc(orders.createdAt))
.limit(limit)
.offset(offset)
}
export async function getRevenueStats(startDate: Date, endDate: Date) {
const [stats] = await db
.select({
orderCount: count(),
totalRevenueCents: sum(orders.totalCents),
avgOrderCents: avg(orders.totalCents),
})
.from(orders)
.where(
and(
gte(orders.createdAt, startDate),
lte(orders.createdAt, endDate),
eq(orders.status, "delivered")
)
)
return {
orderCount: Number(stats.orderCount),
totalRevenueCents: Number(stats.totalRevenueCents ?? 0),
avgOrderCents: Number(stats.avgOrderCents ?? 0),
}
}
// Raw SQL for complex analytics
export async function getTopCustomersByRevenue(limit = 10) {
return await sql`
SELECT
c.id,
c.email,
c.name,
COUNT(o.id) as order_count,
SUM(o.total_cents) as total_cents
FROM customers c
JOIN orders o ON c.id = o.customer_id
WHERE o.status != 'cancelled'
GROUP BY c.id, c.email, c.name
ORDER BY total_cents DESC
LIMIT ${limit}
`
}
Cloudflare Worker with Neon
// src/worker.ts — Neon in Cloudflare Worker
import { neon } from "@neondatabase/serverless"
import { drizzle } from "drizzle-orm/neon-http"
import * as schema from "./schema"
interface Env {
DATABASE_URL: string
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const sql = neon(env.DATABASE_URL)
const db = drizzle(sql, { schema })
const url = new URL(request.url)
if (url.pathname.startsWith("/api/orders")) {
const customerId = url.searchParams.get("customer_id")
if (!customerId) return Response.json({ error: "Missing customer_id" }, { status: 400 })
const customerOrders = await db
.select()
.from(schema.orders)
.where(eq(schema.orders.customerId, customerId))
.orderBy(desc(schema.orders.createdAt))
.limit(20)
return Response.json(customerOrders)
}
return Response.json({ error: "Not found" }, { status: 404 })
},
}
Branch Provisioning for Preview Environments
// scripts/create-preview-branch.ts — Neon API branching
const NEON_API = "https://console.neon.tech/api/v2"
const API_KEY = process.env.NEON_API_KEY!
const PROJECT_ID = process.env.NEON_PROJECT_ID!
interface NeonBranch {
id: string
name: string
connection_string: string
}
export async function createPreviewBranch(prNumber: number): Promise<NeonBranch> {
const branchName = `preview/pr-${prNumber}`
// Create branch from main
const createResponse = await fetch(
`${NEON_API}/projects/${PROJECT_ID}/branches`,
{
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
branch: {
name: branchName,
parent_id: "main", // Branch from main
},
endpoints: [{
type: "read_write",
autoscaling_limit_min_cu: 0.25,
autoscaling_limit_max_cu: 1,
}],
}),
}
)
const { branch, endpoints } = await createResponse.json()
// Get connection string
const connResponse = await fetch(
`${NEON_API}/projects/${PROJECT_ID}/connection_uri?branch_id=${branch.id}&role_name=neondb_owner&database_name=neondb`,
{ headers: { Authorization: `Bearer ${API_KEY}` } }
)
const { uri } = await connResponse.json()
return { id: branch.id, name: branchName, connection_string: uri }
}
export async function deletePreviewBranch(prNumber: number): Promise<void> {
// List branches to find by name
const listResponse = await fetch(
`${NEON_API}/projects/${PROJECT_ID}/branches`,
{ headers: { Authorization: `Bearer ${API_KEY}` } }
)
const { branches } = await listResponse.json()
const branch = branches.find((b: any) => b.name === `preview/pr-${prNumber}`)
if (!branch) return
await fetch(
`${NEON_API}/projects/${PROJECT_ID}/branches/${branch.id}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${API_KEY}` },
}
)
}
# .github/workflows/preview.yml — auto-provision and deprovision Neon branches
name: Preview Environment
on:
pull_request:
types: [opened, synchronize, reopened, closed]
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create Neon branch
if: github.event.action != 'closed'
id: neon
run: |
OUTPUT=$(npx tsx scripts/create-preview-branch.ts ${{ github.event.pull_request.number }})
echo "connection_string=$(echo $OUTPUT | jq -r .connection_string)" >> $GITHUB_OUTPUT
env:
NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
NEON_PROJECT_ID: ${{ secrets.NEON_PROJECT_ID }}
- name: Run migrations on branch
if: github.event.action != 'closed'
run: npm run db:migrate
env:
DATABASE_URL: ${{ steps.neon.outputs.connection_string }}
- name: Delete Neon branch
if: github.event.action == 'closed'
run: npx tsx scripts/create-preview-branch.ts delete ${{ github.event.pull_request.number }}
env:
NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
NEON_PROJECT_ID: ${{ secrets.NEON_PROJECT_ID }}
For the PlanetScale MySQL alternative that offers similar branching workflows with a non-blocking schema change workflow instead of PostgreSQL, the database migrations guide covers multi-environment migration patterns. For Turso SQLite-at-the-edge when PostgreSQL semantics aren’t required and sub-millisecond edge reads matter most, see the Turso guide for libSQL and Drizzle integration. The Claude Skills 360 bundle includes Neon skill sets covering serverless connections, branching workflows, and Drizzle integration. Start with the free tier to try Neon PostgreSQL configuration generation.