Serverless Postgres sounds perfect until your Vercel function hits too many connections under moderate traffic. Here's what I learned running Neon in production for 6 months across three SaaS apps.
The Problem With Serverless + Postgres
Traditional Postgres holds one OS-level process per connection. Each Next.js serverless function invocation wants its own connection. With 100 concurrent requests, that's 100 Postgres processes — most databases cap out around 100-200 connections before performance degrades.
Neon solves this two ways:
-
Neon's built-in connection pooler (PgBouncer under the hood) — pool URL ends in
-pooler.neon.tech -
@neondatabase/serverlessdriver — WebSocket-based, opens connections faster than TCP, works in edge runtimes
Most tutorials tell you to use one. You often need both.
Setup: The Right Way
Install
npm install @neondatabase/serverless drizzle-orm drizzle-orm/neon-serverless
Two connection strings — know when to use each
# .env.local
# Standard connection — use for migrations, seeds, long-running queries
DATABASE_URL=postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/mydb
# Pooled connection — use for serverless functions, API routes
DATABASE_POOLED_URL=postgresql://user:pass@ep-xxx-pooler.us-east-2.aws.neon.tech/mydb?pgbouncer=true
The ?pgbouncer=true flag disables prepared statements — PgBouncer doesn't support them in transaction mode. Miss this and you'll get cryptic errors.
Drizzle + Neon setup
// lib/db.ts
import { neon, neonConfig } from '@neondatabase/serverless'
import { drizzle } from 'drizzle-orm/neon-http'
import * as schema from './schema'
// Enable WebSocket pooling in Node.js environments
neonConfig.fetchConnectionCache = true
// Use pooled URL for API routes
const sql = neon(process.env.DATABASE_POOLED_URL!)
export const db = drizzle(sql, { schema })
// lib/db-migrate.ts — separate file for migrations
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import * as schema from './schema'
// Use standard URL (not pooled) for migrations
const pool = new Pool({ connectionString: process.env.DATABASE_URL! })
export const dbMigrate = drizzle(pool, { schema })
Connection Limits in Practice
Neon's free tier: max 100 connections total across all branches.
With PgBouncer pooler: each pool counts as one server-side connection regardless of how many clients.
I've run 500 concurrent serverless function invocations against a free-tier Neon project without hitting limits — as long as every function uses the pooled URL.
The Mistakes That Burned Me
1. Running migrations against the pooled URL
PgBouncer in transaction mode doesn't support SET commands, advisory locks, or multi-statement transactions — all things Drizzle's migration runner uses. Always run drizzle-kit migrate against the standard (non-pooled) URL.
Add this to your package.json:
{"scripts":{"db:migrate":"DATABASE_URL=$DATABASE_URL drizzle-kit migrate","db:studio":"DATABASE_URL=$DATABASE_URL drizzle-kit studio"}}
2. Not using branches for staging
Neon's branching is the actual killer feature. Create a branch per PR:
# .github/workflows/preview.yml
- name: Create Neon branch
uses: neondatabase/create-branch-action@v5
with:
project_id: ${{ secrets.NEON_PROJECT_ID }}
branch_name: preview/${{ github.event.number }}
api_key: ${{ secrets.NEON_API_KEY }}
Each preview environment gets its own Postgres with data copied from main — no shared state between PRs, no test data leaking.
3. Forgetting ?sslmode=require in non-Neon environments
Neon requires SSL. If you ever point the same codebase at a local Postgres, make sure your connection string handling is environment-aware:
const connectionString = process.env.NODE_ENV === 'development' && process.env.DATABASE_URL?.includes('localhost')
? process.env.DATABASE_URL
: `${process.env.DATABASE_POOLED_URL}${process.env.DATABASE_POOLED_URL?.includes('?') ? '&' : '?'}sslmode=require`
Edge Runtime Compatibility
The @neondatabase/serverless driver works in Vercel Edge Functions and Cloudflare Workers — it uses fetch/WebSockets, not Node.js net. The standard pg driver does not.
// app/api/fast/route.ts — edge runtime
export const runtime = 'edge'
import { neon } from '@neondatabase/serverless'
export async function GET() {
const sql = neon(process.env.DATABASE_POOLED_URL!)
const [row] = await sql`SELECT count(*) FROM users`
return Response.json({ count: row.count })
}
If you're using Drizzle with edge, use drizzle-orm/neon-http not drizzle-orm/node-postgres.
Observability
Neon's dashboard shows active connections, query counts, and compute time. The metric to watch is active connections on the pooler — if it consistently stays near your limit (100 for free, 500 for Launch), you're close to the ceiling.
I log slow queries via Drizzle's logger:
class SlowQueryLogger implements Logger {
logQuery(query: string, params: unknown[]) {
const start = performance.now()
// wrap in performance.now() post-execution to log > 1000ms
}
}
For production monitoring, Neon integrates directly with Datadog and exports to OpenTelemetry.
Summary
- Always use the pooled URL (
-pooler.neon.tech) in serverless functions - Use the standard URL for migrations only
- Add
?pgbouncer=trueto the pooled URL with Drizzle - Use Neon branches for preview environments — it's the feature that justifies the platform
-
@neondatabase/serverlessis edge-compatible;pgis not
Shipping a Next.js SaaS with Postgres? The AI SaaS Starter Kit has Neon + Drizzle + Stripe + Claude pre-wired with correct pooling setup out of the box.