Skip to main content
← All Articles · · 14 min read

Neon Serverless Postgres: Complete Guide for 2026

Complete guide to Neon Serverless Postgres covering branching, autoscaling, connection pooling with PgBouncer, integration with Next.js and Drizzle ORM, and how it compares to Supabase and PlanetScale.

neonpostgresserverlessdatabasenextjsdrizzle

If you’re still provisioning a $40/month RDS instance for a side project that gets 200 users a week, you’re doing it wrong. Neon Serverless Postgres has fundamentally changed the economics and developer experience of running Postgres — and in 2026, it’s the default choice for every new project I start.

This guide is a deep technical walkthrough: how Neon actually works, why its branching model is a genuine paradigm shift, and how to wire it up with Next.js 15, Drizzle ORM, and Prisma with production-grade configuration.


What Is Neon and Why It’s Different

Neon is a fully managed, serverless Postgres database built on top of a custom storage engine that separates compute from storage. Unlike traditional managed Postgres (RDS, Cloud SQL), Neon’s compute nodes can scale to zero when idle and spin back up in under 500 milliseconds.

That architectural separation — compute and storage are independent layers — is what enables every compelling Neon feature:

  • Scale to zero: Your database literally turns off when unused. Free tier projects stay dormant until a request arrives.
  • Instant branching: Because storage is a shared, copy-on-write layer, you can fork an entire database state in under a second with no data duplication.
  • Autoscaling: Compute scales between a minimum and maximum vCPU allocation per request load.

The people who built Neon came from Citus Data (acquired by Microsoft). They know Postgres internals cold. Neon is not a Postgres wrapper or a compatibility shim — it’s upstream Postgres 16/17 running on a custom distributed storage engine called Zenith (now just “the Neon storage layer”). Every Postgres extension, function, and SQL feature works as expected.


Setting Up Neon: First Steps

Getting a Neon project running takes under two minutes.

  1. Sign up at neon.tech
  2. Create a new project — choose a region, Postgres version (go with 17), and project name
  3. Neon auto-creates a main branch with a default database

Your connection string looks like this:

postgresql://alex:[email protected]/neondb?sslmode=require

The ep-cool-darkness-123456 segment is your endpoint ID — this maps to a specific compute instance on a specific branch. When you branch your database, you get a new endpoint ID.

Environment Setup

Install the Neon serverless driver:

npm install @neondatabase/serverless

For direct pg compatibility (when not on Edge runtime):

npm install pg
npm install -D @types/pg

Set your environment variables:

# .env.local
DATABASE_URL="postgresql://alex:[email protected]/neondb?sslmode=require"

# Pooled connection (for serverless functions)
DATABASE_URL_UNPOOLED="postgresql://alex:[email protected]/neondb?sslmode=require"
DATABASE_URL_POOLED="postgresql://alex:[email protected]/neondb?sslmode=require&pgbouncer=true"

Keep both pooled and unpooled URLs. You’ll use the unpooled connection for migrations and the pooled one for runtime queries.


Database Branching: The Killer Feature

This is the feature that made me switch everything to Neon. Database branching works exactly like Git branching — you create a named branch from any point in your database history, and it immediately has a full copy of all data and schema at that point.

The crucial detail: it uses copy-on-write semantics. Creating a branch doesn’t duplicate your data. It just records a pointer to the shared storage page. Pages only diverge (and consume extra storage) when you actually write to the branch.

Creating Branches via CLI

# Install Neon CLI
npm install -g neonctl

# Authenticate
neonctl auth

# Create a branch from main
neonctl branches create --name feature/new-billing-schema --parent main

# List all branches
neonctl branches list

# Get connection string for a branch
neonctl connection-string feature/new-billing-schema

Creating Branches via API

const response = await fetch('https://console.neon.tech/api/v2/projects/{project_id}/branches', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.NEON_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    endpoints: [{ type: 'read_write' }],
    branch: {
      name: `preview/pr-${prNumber}`,
      parent_id: 'main',
    },
  }),
})

const { branch, endpoints } = await response.json()
const connectionString = endpoints[0].host

Why This Matters in Practice

Before Neon branching, the database was always the awkward coupling point in CI/CD. Your code could be reviewed in isolation, but your schema changes couldn’t. Teams either:

  • Tested against a shared dev database (race conditions, conflicts, mysteries)
  • Spun up Docker Postgres containers in CI (no real data, different behavior from prod)
  • Just didn’t test database migrations in isolation (chaos)

With Neon branching, your PR pipeline can:

  1. Create a database branch from main when a PR opens
  2. Run migrations against that branch
  3. Run integration tests with real production-shaped data
  4. Tear down the branch when the PR closes

Zero shared state, zero Docker overhead, real data shapes.


Autoscaling and Scale-to-Zero Explained

Neon’s compute autoscaling operates in two dimensions:

Scale-to-zero: After a configurable idle period (default 5 minutes, configurable down to 1 minute on Pro), the compute node suspends. The storage layer stays live. When a new connection arrives, the compute node wakes up — typically in 300-500ms for cold starts, sub-100ms for warm.

Vertical autoscaling: Within a set min/max range (e.g., 0.25 vCPU to 4 vCPU), Neon scales compute up under load and back down when idle. This is measured in Compute Units (CUs) — 1 CU = 1 vCPU + 4GB RAM.

For most side projects and early-stage products, this means you pay for almost nothing during off-hours. On the free tier, you get 512MB RAM and 0.25 vCPU, which handles surprisingly real workloads for a small app.

Cold Start Mitigation

If the 300-500ms cold start is unacceptable for your use case (latency-sensitive endpoints), you have options:

// Connection with retry for cold starts
import { neon } from '@neondatabase/serverless'

async function queryWithRetry<T>(
  sql: ReturnType<typeof neon>,
  query: string,
  params: unknown[] = [],
  retries = 3
): Promise<T[]> {
  for (let i = 0; i < retries; i++) {
    try {
      return await sql(query, params) as T[]
    } catch (err: unknown) {
      const error = err as { code?: string }
      if (error.code === 'ECONNREFUSED' && i < retries - 1) {
        await new Promise(resolve => setTimeout(resolve, 500 * (i + 1)))
        continue
      }
      throw err
    }
  }
  throw new Error('Max retries exceeded')
}

For production APIs where every millisecond matters, consider keeping the connection pool warm with a scheduled ping, or move to the Pro plan where you can set the minimum compute to 0.25 CU (no scale-to-zero).


Connection Pooling with PgBouncer Built-In

Traditional Postgres has a hard ceiling on concurrent connections — each connection is a forked OS process consuming ~5-10MB of RAM. In serverless environments (Vercel functions, Cloudflare Workers), you can easily exhaust connection limits with normal traffic spikes.

Neon solves this by providing a built-in PgBouncer connection pooler at the infrastructure level. Your pooled connection string routes through PgBouncer automatically:

# Direct connection (for migrations, DDL, prepared statements)
ep-cool-darkness-123456.us-east-2.aws.neon.tech

# Pooled connection (for serverless runtime queries)
ep-cool-darkness-123456-pooler.us-east-2.aws.neon.tech

The pooler runs in transaction mode — each transaction gets a real Postgres connection from the pool, released immediately after the transaction commits. This means:

  • Session-level state doesn’t persist between queries
  • SET session_authorization and other session-level commands won’t behave as expected
  • Prepared statements need the pgbouncer=true parameter in the connection string to work

For 99% of application queries, transaction mode is exactly what you want.


Integration with Next.js 15 + Drizzle ORM

Drizzle ORM is the best pairing for Neon in 2026 — it’s lightweight, fully TypeScript-native, and its query builder maps directly to SQL without abstraction overhead.

Installation

npm install drizzle-orm @neondatabase/serverless
npm install -D drizzle-kit

Schema Definition

// src/db/schema.ts
import { pgTable, serial, text, timestamp, integer, boolean } from 'drizzle-orm/pg-core'

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  content: text('content').notNull(),
  published: boolean('published').default(false).notNull(),
  authorId: integer('author_id').references(() => users.id).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})

export type User = typeof users.$inferSelect
export type NewUser = typeof users.$inferInsert
export type Post = typeof posts.$inferSelect
export type NewPost = typeof posts.$inferInsert

Database Client Setup

// src/db/index.ts
import { neon } from '@neondatabase/serverless'
import { drizzle } from 'drizzle-orm/neon-http'
import * as schema from './schema'

// Use pooled connection for runtime
const sql = neon(process.env.DATABASE_URL_POOLED!)

export const db = drizzle(sql, { schema })

Drizzle Config

// drizzle.config.ts
import type { Config } from 'drizzle-kit'

export default {
  schema: './src/db/schema.ts',
  out: './drizzle/migrations',
  dialect: 'postgresql',
  dbCredentials: {
    // Use unpooled for migrations (DDL doesn't work through PgBouncer in transaction mode)
    url: process.env.DATABASE_URL_UNPOOLED!,
  },
} satisfies Config

Query Examples in Next.js 15 Server Components

With Next.js 15’s async APIs, you can query Neon directly from Server Components without any API layer:

// app/posts/page.tsx
import { db } from '@/db'
import { posts, users } from '@/db/schema'
import { eq, desc } from 'drizzle-orm'

export default async function PostsPage() {
  const allPosts = await db
    .select({
      id: posts.id,
      title: posts.title,
      authorName: users.name,
      createdAt: posts.createdAt,
    })
    .from(posts)
    .leftJoin(users, eq(posts.authorId, users.id))
    .where(eq(posts.published, true))
    .orderBy(desc(posts.createdAt))
    .limit(20)

  return (
    <ul>
      {allPosts.map(post => (
        <li key={post.id}>
          <h2>{post.title}</h2>
          <p>by {post.authorName}</p>
        </li>
      ))}
    </ul>
  )
}

Server Actions with Drizzle

// app/posts/actions.ts
'use server'

import { db } from '@/db'
import { posts } from '@/db/schema'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
  authorId: z.number().int().positive(),
})

export async function createPost(formData: FormData) {
  const validated = CreatePostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
    authorId: Number(formData.get('authorId')),
  })

  if (!validated.success) {
    return { error: validated.error.flatten() }
  }

  const [newPost] = await db
    .insert(posts)
    .values({
      ...validated.data,
      published: false,
    })
    .returning()

  revalidatePath('/posts')
  return { post: newPost }
}

Integration with Next.js 15 + Prisma

If you’re already on Prisma or prefer its schema language, Neon works seamlessly — with one important configuration difference.

Installation

npm install prisma @prisma/client
npm install @neondatabase/serverless
npx prisma init

Prisma Schema

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
  // Use direct connection for migrations
  directUrl = env("DATABASE_URL_UNPOOLED")
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String
  posts     Post[]
  createdAt DateTime @default(now())
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int
  createdAt DateTime @default(now())
}

Singleton Prisma Client (Critical for Serverless)

// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
  })

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

The singleton pattern is mandatory with Next.js hot reload in development — without it, you’ll exhaust Postgres connections within minutes of editing files.

Prisma Accelerate for Edge

For Edge runtime, Prisma offers Prisma Accelerate as a connection pooling proxy. However, Neon’s built-in pooler often makes Accelerate redundant — check your latency requirements before adding another hop.


Edge Runtime Support

Neon’s @neondatabase/serverless driver is purpose-built for Edge runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy). It uses HTTP/WebSocket transport instead of the TCP connection that standard pg requires.

// Using neon() for one-shot queries (no connection overhead)
import { neon } from '@neondatabase/serverless'

export const runtime = 'edge'

export async function GET(request: Request) {
  const sql = neon(process.env.DATABASE_URL!)

  // Tagged template literal — automatically parameterized, safe from SQL injection
  const userId = new URL(request.url).searchParams.get('id')
  const [user] = await sql`SELECT * FROM users WHERE id = ${userId} LIMIT 1`

  return Response.json(user)
}
// Using Pool for multiple queries (WebSocket connection reuse)
import { Pool } from '@neondatabase/serverless'

export const runtime = 'edge'

export async function POST(request: Request) {
  const pool = new Pool({ connectionString: process.env.DATABASE_URL })

  try {
    await pool.query('BEGIN')
    await pool.query('INSERT INTO orders (user_id, amount) VALUES ($1, $2)', [1, 99.99])
    await pool.query('UPDATE users SET order_count = order_count + 1 WHERE id = $1', [1])
    await pool.query('COMMIT')
    return Response.json({ success: true })
  } catch (err) {
    await pool.query('ROLLBACK')
    throw err
  } finally {
    await pool.end()
  }
}

The neon() function uses HTTP fetch under the hood — one round-trip per query, zero connection overhead. Pool uses WebSockets and is better when you need transactions or multiple queries per request.


Neon vs Supabase vs PlanetScale vs Railway

FeatureNeonSupabasePlanetScaleRailway
DatabasePostgres 17Postgres 15MySQL (Vitess)Postgres / MySQL
BranchingNative, instantNoYes (deploy requests)No
Scale to zeroYes (all tiers)No (free only, pauses)YesNo
Edge driver@neondatabase/serverless@supabase/supabase-js@planetscale/databaseStandard pg
Connection poolingBuilt-in PgBouncerPgBouncer via SupavisorBuilt-in (HTTP)Manual / Pgpool
Auth built-inNoYes (full auth system)NoNo
RealtimeNoYes (Realtime subscriptions)NoNo
Free tier storage512MB500MB5GB1GB
Free tier compute191.9 compute hours/monthLimited, pauses after 7 days1B row readsVaries
SQL compatibilityFull PostgresFull PostgresMySQL onlyFull
Pricing modelCompute + storageCompute + storageRow reads/writesUsage-based

My take: Supabase wins if you need auth, realtime, or storage primitives bundled with your database. PlanetScale wins if you’re on MySQL and need their deploy request workflow. Neon wins if you want pure Postgres, maximum branching flexibility, and predictable serverless economics. Railway is for when you want a traditional deployment model without managed database complexity.


Pricing: Free Tier vs Paid

Free tier (Forever free):

  • 0.5 GB storage
  • 191.9 compute hours/month (roughly: one 0.25 CU project running 24/7 for a month)
  • 1 project, 10 branches
  • Scale-to-zero always on

Launch ($19/month):

  • 10 GB storage included
  • 300 compute hours/month
  • Unlimited projects
  • Configurable autoscaling (0.25–4 CU)
  • Logical replication

Scale ($69/month):

  • 50 GB storage included
  • 750 compute hours/month
  • Priority support
  • Archive storage tiers

When to use free tier: Side projects, prototypes, hackathons, and development/staging environments for paid projects. The 191.9 compute hours is genuinely generous for a hobby project.

When to upgrade: The moment you have a production app with real users. Not because of storage (most early apps are tiny), but because scale-to-zero cold starts become user-visible latency, and the configurable autoscaling minimum on Launch prevents those cold starts.

Real cost for a small SaaS: A typical early-stage product on Launch might use 2-4 GB storage and 200-400 compute hours. That’s $19-$35/month all-in. Compare that to $50+ for a minimal RDS instance.


Real-World Pattern: Preview Environments with Database Branches

This is the workflow I use on every project now. Here’s the full GitHub Actions implementation:

# .github/workflows/preview.yml
name: Preview Deployment

on:
  pull_request:
    types: [opened, synchronize, reopened, closed]

jobs:
  deploy-preview:
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Neon CLI
        run: npm install -g neonctl

      - name: Create DB branch
        id: neon-branch
        run: |
          BRANCH_NAME="preview/pr-${{ github.event.number }}"
          neonctl branches create \
            --project-id ${{ secrets.NEON_PROJECT_ID }} \
            --name "$BRANCH_NAME" \
            --parent main \
            --api-key ${{ secrets.NEON_API_KEY }}

          CONNECTION_STRING=$(neonctl connection-string "$BRANCH_NAME" \
            --project-id ${{ secrets.NEON_PROJECT_ID }} \
            --api-key ${{ secrets.NEON_API_KEY }} \
            --pooled)

          echo "connection_string=$CONNECTION_STRING" >> $GITHUB_OUTPUT
          echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT

      - name: Run migrations on branch
        run: npx drizzle-kit push
        env:
          DATABASE_URL_UNPOOLED: ${{ steps.neon-branch.outputs.connection_string }}

      - name: Deploy to Vercel with branch DB
        run: |
          vercel deploy \
            --env DATABASE_URL="${{ steps.neon-branch.outputs.connection_string }}" \
            --token ${{ secrets.VERCEL_TOKEN }}

  cleanup-preview:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    steps:
      - name: Setup Neon CLI
        run: npm install -g neonctl

      - name: Delete DB branch
        run: |
          neonctl branches delete "preview/pr-${{ github.event.number }}" \
            --project-id ${{ secrets.NEON_PROJECT_ID }} \
            --api-key ${{ secrets.NEON_API_KEY }}

Every PR gets its own full Postgres database, seeded from main, with migrations pre-applied. Reviewers can test against real-looking data. When the PR closes, the branch is deleted automatically. No shared state, no leftover test data polluting your dev database, no developer waiting for a CI database lock to release.

This pattern alone justifies adopting Neon on any team project.


Migration Strategy and Best Practices

A few things learned from running Neon in production:

Always use directUrl for migrations. PgBouncer in transaction mode doesn’t support all DDL operations or session-level settings that migration tools rely on. Set DATABASE_URL to the pooled endpoint and DATABASE_URL_UNPOOLED (or directUrl in Prisma) to the direct connection.

Lock your Postgres version. Neon supports Postgres 14, 15, 16, and 17. Pick 17 for new projects — but once you’ve picked, don’t change it. Neon doesn’t do in-place major version upgrades; you’d need to create a new project and migrate data.

Use connection string rotation for branches. When using branches for feature development, store the branch connection string in your local .env file, not .env.local — this makes it easier to switch branches without touching your Next.js-specific configuration.

Monitor compute hours. Neon’s console shows compute hour usage in real time. If you’re burning through your free tier faster than expected, check for background jobs or cron tasks that are keeping compute warm unnecessarily.


Final Verdict

Neon is the best Postgres option for developers in 2026 if you’re building on a modern serverless stack. The branching feature alone is worth the migration from any other provider. The economics make it unbeatable for anything from a side project to early-stage production.

The gaps are real: no built-in auth, no realtime subscriptions, no object storage. If you need those, add them as separate services (Clerk for auth, Pusher or Ably for realtime, R2/S3 for storage) or consider Supabase. But if you want a focused, expert-quality Postgres service with the best developer experience for CI/CD and serverless workloads, Neon is the default answer.


Further Reading

Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.