Neon - Complete Guide to Serverless PostgreSQL
What is Neon?
Neon is a next-generation serverless PostgreSQL, designed from the ground up for modern cloud applications. Unlike traditional hosted databases, Neon offers a true serverless model with auto-scaling to zero, Git-like database branching, and pay-per-use pricing.
Neon's architecture separates compute from storage, enabling instant scaling, fast cold starts (~500ms), and unlimited database branches without additional storage costs.
Why Neon?
Key advantages
- Database branching - Create database copies in seconds, without physically copying data
- Scale to zero - No charges when the application is not in use
- Instant wake - Cold start under 500ms
- Pay per use - Only pay for actual compute hours
- Full PostgreSQL compatibility - Support for pgvector, PostGIS, and other extensions
- Secure connections - All connections encrypted with TLS
Neon vs traditional databases
| Feature | Neon | RDS/Cloud SQL | Supabase |
|---|---|---|---|
| Scale to zero | Yes | No | No |
| Branching | Native | Snapshots | No |
| Cold start | ~500ms | N/A | N/A |
| Pricing model | Per compute | Per hour | Flat + usage |
| Free tier storage | 512MB | None | 500MB |
| Connection pooling | Built-in | Extra cost | Built-in |
When to choose Neon?
- Side projects and MVPs - Free plan with scale to zero
- Preview environments - Separate database for each PR
- CI/CD testing - Isolated test environments
- Serverless backends - Works perfectly with Edge Functions
- Development workflow - Branching for feature development
When to consider alternatives?
- Constant, high load - A dedicated instance may be cheaper
- Very low latency - A self-hosted server may be faster
- Specific PG extensions - Check availability in Neon
Neon Architecture
Compute and Storage Separation
┌─────────────────────────────────────────────────────────────┐
│ Neon Cloud │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Compute │ │ Compute │ │ Compute │ │
│ │ (main) │ │ (dev) │ │ (pr-123) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └────────────────────┼───────────────────┘ │
│ │ │
│ ┌───────▼───────┐ │
│ │ Pageserver │ │
│ │ (Storage) │ │
│ └───────┬───────┘ │
│ │ │
│ ┌───────▼───────┐ │
│ │ Safekeepers │ │
│ │ (WAL/Durability) │
│ └───────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘How does branching work?
main branch
│
├── commit 1
│
├── commit 2
│ │
│ └──► dev branch (copy-on-write)
│ │
│ ├── dev commit 1
│ │
│ └── dev commit 2
│
├── commit 3
│ │
│ └──► pr-123 branch
│
└── commit 4A branch in Neon is a copy-on-write snapshot - it does not physically copy data, only metadata. That is why creating a branch takes seconds, regardless of the database size.
Getting started
Creating a project
- Sign up at console.neon.tech
- Create a new project
- Choose a region (available: US East, US West, Europe, Asia)
- Copy the connection string
Connection String
postgresql://[user]:[password]@[host]/[database]?sslmode=requireExample:
postgresql://neonuser:password123@ep-cool-name-123456.us-east-2.aws.neon.tech/neondb?sslmode=requireCLI Installation
# npm
npm install -g neonctl
# Homebrew (macOS)
brew install neonctl
# Authorization
neonctl authBranching - Git for your database
Creating branches via CLI
# List branches
neonctl branches list
# Create a branch from main
neonctl branches create --name dev
# Create a branch from a specific point in time
neonctl branches create --name staging --parent main --point-in-time "2024-01-15T10:00:00Z"
# Create a branch for a PR
neonctl branches create --name pr-456 --parent main
# Delete a branch
neonctl branches delete pr-456Branching in CI/CD
# .github/workflows/preview.yml
name: Preview Environment
on:
pull_request:
types: [opened, synchronize]
jobs:
create-preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create Neon Branch
id: create-branch
uses: neondatabase/create-branch-action@v5
with:
project_id: ${{ secrets.NEON_PROJECT_ID }}
branch_name: pr-${{ github.event.pull_request.number }}
api_key: ${{ secrets.NEON_API_KEY }}
- name: Run Migrations
run: |
DATABASE_URL="${{ steps.create-branch.outputs.db_url }}" npm run migrate
- name: Deploy Preview
run: |
# Deploy with the new DATABASE_URL
vercel --env DATABASE_URL="${{ steps.create-branch.outputs.db_url }}"Automatic branch cleanup
# .github/workflows/cleanup.yml
name: Cleanup Preview
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Delete Neon Branch
uses: neondatabase/delete-branch-action@v3
with:
project_id: ${{ secrets.NEON_PROJECT_ID }}
branch_name: pr-${{ github.event.pull_request.number }}
api_key: ${{ secrets.NEON_API_KEY }}Connecting to your application
Neon Serverless Driver
The official driver optimized for serverless:
npm install @neondatabase/serverlessimport { neon } from '@neondatabase/serverless'
const sql = neon(process.env.DATABASE_URL!)
// Simple query
const users = await sql`SELECT * FROM users WHERE active = true`
// With parameters
const userId = 1
const user = await sql`SELECT * FROM users WHERE id = ${userId}`
// Insert
const newUser = await sql`
INSERT INTO users (name, email)
VALUES (${'John Doe'}, ${'john@example.com'})
RETURNING *
`
// Transaction (single statement)
const result = await sql`
WITH inserted AS (
INSERT INTO orders (user_id, total)
VALUES (${userId}, ${99.99})
RETURNING id
)
INSERT INTO order_items (order_id, product_id, quantity)
SELECT id, ${productId}, ${quantity} FROM inserted
RETURNING *
`Neon with Pool (for long-lived connections)
import { Pool } from '@neondatabase/serverless'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
// Using with pool
const client = await pool.connect()
try {
await client.query('BEGIN')
await client.query('INSERT INTO orders ...', [values])
await client.query('UPDATE inventory ...', [values])
await client.query('COMMIT')
} catch (e) {
await client.query('ROLLBACK')
throw e
} finally {
client.release()
}
// Close pool on shutdown
await pool.end()WebSocket for real-time
import { neon, neonConfig } from '@neondatabase/serverless'
import ws from 'ws'
// For Node.js environments (Vercel Edge, Cloudflare Workers have built-in WS)
neonConfig.webSocketConstructor = ws
const sql = neon(process.env.DATABASE_URL!)ORM Integration
Drizzle ORM
npm install drizzle-orm @neondatabase/serverless
npm install -D drizzle-kit// db/schema.ts
import { pgTable, serial, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core'
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
createdAt: timestamp('created_at').defaultNow(),
isActive: boolean('is_active').default(true),
})
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content'),
authorId: integer('author_id').references(() => users.id),
publishedAt: timestamp('published_at'),
})// db/index.ts
import { drizzle } from 'drizzle-orm/neon-http'
import { neon } from '@neondatabase/serverless'
import * as schema from './schema'
const sql = neon(process.env.DATABASE_URL!)
export const db = drizzle(sql, { schema })
// Usage
const allUsers = await db.select().from(schema.users)
const usersWithPosts = await db.query.users.findMany({
with: {
posts: true,
},
})
// Insert
await db.insert(schema.users).values({
name: 'John Smith',
email: 'john@example.com',
})
// Update
await db.update(schema.users)
.set({ isActive: false })
.where(eq(schema.users.id, 1))
// Delete
await db.delete(schema.users).where(eq(schema.users.id, 1))// drizzle.config.ts
import type { Config } from 'drizzle-kit'
export default {
schema: './db/schema.ts',
out: './drizzle',
driver: 'pg',
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
},
} satisfies Config# Generate migrations
npx drizzle-kit generate:pg
# Run migrations
npx drizzle-kit push:pg
# Studio (GUI)
npx drizzle-kit studioPrisma
npm install prisma @prisma/client
npx prisma init// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
name String
email String @unique
posts Post[]
createdAt DateTime @default(now())
isActive Boolean @default(true)
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
author User @relation(fields: [authorId], references: [id])
authorId Int
publishedAt DateTime?
}// lib/prisma.ts
import { PrismaClient } from '@prisma/client'
import { PrismaNeon } from '@prisma/adapter-neon'
import { Pool } from '@neondatabase/serverless'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaNeon(pool)
export const prisma = new PrismaClient({ adapter })
// Usage
const users = await prisma.user.findMany({
include: { posts: true },
})
await prisma.user.create({
data: {
name: 'John Smith',
email: 'john@example.com',
posts: {
create: {
title: 'First post',
content: 'Post content...',
},
},
},
})# Migrations
npx prisma migrate dev --name init
npx prisma migrate deploy
# Studio
npx prisma studioKysely
npm install kysely @neondatabase/serverless// db/types.ts
import { Generated, Insertable, Selectable, Updateable } from 'kysely'
export interface Database {
users: UsersTable
posts: PostsTable
}
interface UsersTable {
id: Generated<number>
name: string
email: string
created_at: Generated<Date>
is_active: Generated<boolean>
}
interface PostsTable {
id: Generated<number>
title: string
content: string | null
author_id: number
published_at: Date | null
}
export type User = Selectable<UsersTable>
export type NewUser = Insertable<UsersTable>
export type UserUpdate = Updateable<UsersTable>// db/index.ts
import { Kysely } from 'kysely'
import { NeonDialect } from 'kysely-neon'
import { Database } from './types'
export const db = new Kysely<Database>({
dialect: new NeonDialect({
connectionString: process.env.DATABASE_URL!,
}),
})
// Usage
const users = await db
.selectFrom('users')
.selectAll()
.where('is_active', '=', true)
.execute()
await db
.insertInto('users')
.values({ name: 'John', email: 'john@example.com' })
.execute()Next.js Integration
App Router with Server Actions
// app/actions/users.ts
'use server'
import { db } from '@/db'
import { users } from '@/db/schema'
import { eq } from 'drizzle-orm'
import { revalidatePath } from 'next/cache'
export async function getUsers() {
return db.select().from(users)
}
export async function createUser(formData: FormData) {
const name = formData.get('name') as string
const email = formData.get('email') as string
await db.insert(users).values({ name, email })
revalidatePath('/users')
}
export async function deleteUser(id: number) {
await db.delete(users).where(eq(users.id, id))
revalidatePath('/users')
}// app/users/page.tsx
import { getUsers } from '@/app/actions/users'
import { UserForm } from './user-form'
import { UserList } from './user-list'
export default async function UsersPage() {
const users = await getUsers()
return (
<div>
<h1>Users</h1>
<UserForm />
<UserList users={users} />
</div>
)
}// app/users/user-form.tsx
'use client'
import { createUser } from '@/app/actions/users'
export function UserForm() {
return (
<form action={createUser}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit">Add User</button>
</form>
)
}API Routes
// app/api/users/route.ts
import { db } from '@/db'
import { users } from '@/db/schema'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
export async function GET() {
try {
const result = await db.select().from(users)
return NextResponse.json(result)
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch users' },
{ status: 500 }
)
}
}
export async function POST(request: Request) {
try {
const body = await request.json()
const { name, email } = body
const result = await db
.insert(users)
.values({ name, email })
.returning()
return NextResponse.json(result[0], { status: 201 })
} catch (error) {
return NextResponse.json(
{ error: 'Failed to create user' },
{ status: 500 }
)
}
}// app/api/users/[id]/route.ts
import { db } from '@/db'
import { users } from '@/db/schema'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const user = await db
.select()
.from(users)
.where(eq(users.id, parseInt(params.id)))
.limit(1)
if (!user.length) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
return NextResponse.json(user[0])
}
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
) {
const body = await request.json()
const result = await db
.update(users)
.set(body)
.where(eq(users.id, parseInt(params.id)))
.returning()
return NextResponse.json(result[0])
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
await db.delete(users).where(eq(users.id, parseInt(params.id)))
return new NextResponse(null, { status: 204 })
}Edge Functions (Vercel, Cloudflare)
Vercel Edge Functions
// app/api/edge/route.ts
import { neon } from '@neondatabase/serverless'
export const runtime = 'edge'
export async function GET() {
const sql = neon(process.env.DATABASE_URL!)
const users = await sql`SELECT * FROM users LIMIT 10`
return Response.json(users)
}Cloudflare Workers
// src/index.ts
import { neon } from '@neondatabase/serverless'
export interface Env {
DATABASE_URL: string
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const sql = neon(env.DATABASE_URL)
const url = new URL(request.url)
if (url.pathname === '/users') {
const users = await sql`SELECT * FROM users`
return Response.json(users)
}
return new Response('Not Found', { status: 404 })
},
}# wrangler.toml
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-01-01"
[vars]
# Set in Cloudflare Dashboard as a secret
# DATABASE_URL = "..."pgvector - Embeddings and AI
Enabling pgvector
-- In Neon Console or via SQL
CREATE EXTENSION IF NOT EXISTS vector;Schema with vectors
// db/schema.ts
import { pgTable, serial, text, vector } from 'drizzle-orm/pg-core'
export const documents = pgTable('documents', {
id: serial('id').primaryKey(),
content: text('content').notNull(),
embedding: vector('embedding', { dimensions: 1536 }), // OpenAI ada-002
})Semantic Search
import { db } from '@/db'
import { documents } from '@/db/schema'
import { sql } from 'drizzle-orm'
import OpenAI from 'openai'
const openai = new OpenAI()
async function getEmbedding(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: 'text-embedding-ada-002',
input: text,
})
return response.data[0].embedding
}
export async function semanticSearch(query: string, limit = 5) {
const queryEmbedding = await getEmbedding(query)
// Drizzle with raw SQL for vector operations
const results = await db.execute(sql`
SELECT
id,
content,
1 - (embedding <=> ${queryEmbedding}::vector) as similarity
FROM documents
ORDER BY embedding <=> ${queryEmbedding}::vector
LIMIT ${limit}
`)
return results.rows
}
// Adding a document with an embedding
export async function addDocument(content: string) {
const embedding = await getEmbedding(content)
await db.insert(documents).values({
content,
embedding,
})
}RAG Pipeline
// lib/rag.ts
import { semanticSearch, addDocument } from './vector-search'
import OpenAI from 'openai'
const openai = new OpenAI()
export async function askQuestion(question: string): Promise<string> {
// 1. Find relevant documents
const relevantDocs = await semanticSearch(question, 3)
// 2. Build context
const context = relevantDocs
.map(doc => doc.content)
.join('\n\n')
// 3. Generate answer with LLM
const response = await openai.chat.completions.create({
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'system',
content: `Answer questions based on the provided context.
If the answer is not in the context, say so.
Context:
${context}`,
},
{
role: 'user',
content: question,
},
],
})
return response.choices[0].message.content || ''
}Point-in-Time Recovery
Restoring to a point in time
# Via CLI
neonctl branches create \
--name recovery-branch \
--parent main \
--point-in-time "2024-01-15T14:30:00Z"Via API
const response = await fetch('https://console.neon.tech/api/v2/projects/{project_id}/branches', {
method: 'POST',
headers: {
'Authorization': `Bearer ${NEON_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
branch: {
name: 'recovery-branch',
parent_id: 'main',
},
endpoints: [
{
type: 'read_write',
},
],
// Restore to a specific moment
parent_timestamp: '2024-01-15T14:30:00Z',
}),
})Connection Pooling
Neon offers a built-in connection pooler (PgBouncer):
# Pooled connection (for serverless)
postgresql://user:pass@ep-xxx.pooler.region.aws.neon.tech/dbname?sslmode=require
# Direct connection (for migrations)
postgresql://user:pass@ep-xxx.region.aws.neon.tech/dbname?sslmode=requireApplication configuration
// For serverless (most use cases)
const pooledUrl = process.env.DATABASE_URL // use pooled
// For migrations (you need a direct connection)
const directUrl = process.env.DIRECT_DATABASE_URL# .env
DATABASE_URL="postgresql://user:pass@ep-xxx.pooler.us-east-2.aws.neon.tech/neondb?sslmode=require"
DIRECT_DATABASE_URL="postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/neondb?sslmode=require"Monitoring and metrics
Neon Console
- Compute usage - Active compute units
- Storage - Disk usage per branch
- Connections - Active connections
- Query performance - Query analysis
Custom monitoring
import { neon } from '@neondatabase/serverless'
const sql = neon(process.env.DATABASE_URL!)
// Connection statistics
const connectionStats = await sql`
SELECT
count(*) as total_connections,
count(*) FILTER (WHERE state = 'active') as active,
count(*) FILTER (WHERE state = 'idle') as idle
FROM pg_stat_activity
`
// Table sizes
const tableSizes = await sql`
SELECT
relname as table_name,
pg_size_pretty(pg_total_relation_size(relid)) as total_size,
pg_size_pretty(pg_relation_size(relid)) as data_size
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC
`
// Slow queries
const slowQueries = await sql`
SELECT
query,
calls,
mean_exec_time,
total_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10
`Security
Roles and permissions
-- Create a role for the application
CREATE ROLE app_user WITH LOGIN PASSWORD 'secure_password';
GRANT CONNECT ON DATABASE neondb TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
-- Read-only role for analytics
CREATE ROLE analytics_user WITH LOGIN PASSWORD 'analytics_pass';
GRANT CONNECT ON DATABASE neondb TO analytics_user;
GRANT USAGE ON SCHEMA public TO analytics_user;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO analytics_user;Row Level Security
-- Enable RLS
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Policy: user can only see their own posts
CREATE POLICY user_posts ON posts
FOR ALL
USING (author_id = current_setting('app.user_id')::int);
-- Policy: public posts visible to everyone
CREATE POLICY public_posts ON posts
FOR SELECT
USING (is_public = true);// Setting user context
const sql = neon(process.env.DATABASE_URL!)
async function getUserPosts(userId: number) {
await sql`SELECT set_config('app.user_id', ${userId.toString()}, false)`
return sql`SELECT * FROM posts`
}Pricing
Free Tier
- Compute: 0.25 vCPU, 1GB RAM
- Storage: 512MB
- Branching: Unlimited
- Projects: 1
- Price: $0/month
Launch
- Compute: Up to 4 vCPU
- Storage: 10GB included
- Compute hours: 300h/month included
- Price: $19/month
Scale
- Compute: Up to 8 vCPU, autoscaling
- Storage: 50GB included
- Compute hours: 750h/month included
- Price: $69/month
Business
- Compute: Custom
- Storage: 500GB+
- SLA: 99.95%
- Support: Priority
- Price: Custom
Pay-as-you-go pricing
- Compute: $0.102/compute hour
- Storage: $0.000164/GiB-hour (~$0.12/GiB-month)
- Data transfer: Included in price
Best Practices
1. Use branching for development
# Each developer has their own branch
neonctl branches create --name dev-john
neonctl branches create --name dev-anna
# Each PR has its own branch
neonctl branches create --name pr-${PR_NUMBER}2. Connection pooling for serverless
// Always use a pooled connection in serverless
const sql = neon(process.env.DATABASE_URL!) // pooled URL3. Optimize cold starts
// Prewarming via a scheduled function
// Run every 4 minutes to keep the compute active
export async function warmup() {
const sql = neon(process.env.DATABASE_URL!)
await sql`SELECT 1`
}4. Indexes for performance
-- Indexes on frequently used columns
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
CREATE INDEX CONCURRENTLY idx_posts_author ON posts(author_id);
-- Partial index for active records
CREATE INDEX CONCURRENTLY idx_active_users ON users(id) WHERE is_active = true;
-- Index for full-text search
CREATE INDEX CONCURRENTLY idx_posts_content_gin ON posts USING gin(to_tsvector('english', content));5. Monitor and optimize
-- Find missing indexes
SELECT
relname,
seq_scan,
idx_scan,
seq_scan - idx_scan as diff
FROM pg_stat_user_tables
WHERE seq_scan > idx_scan
ORDER BY diff DESC;
-- Query analysis
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';Common problems and solutions
Cold start timeout
// Problem: Query timeout during cold start
// Solution: Increase timeout and add retry
const sql = neon(process.env.DATABASE_URL!, {
fetchOptions: {
timeout: 10000, // 10 seconds
},
})
async function queryWithRetry(query: string, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await sql(query)
} catch (error) {
if (i === retries - 1) throw error
await new Promise(resolve => setTimeout(resolve, 1000))
}
}
}Connection limit exceeded
// Problem: Too many connections
// Solution: Use pooled connection string
// Instead of: ep-xxx.us-east-2.aws.neon.tech
// Use: ep-xxx.pooler.us-east-2.aws.neon.techBranch sync issues
# Problem: Branch outdated relative to main
# Solution: Create a new branch
neonctl branches delete dev-old
neonctl branches create --name dev-new --parent mainFAQ - Frequently asked questions
Is Neon production-ready?
Yes. Neon is used by thousands of companies in production. It offers a 99.95% SLA on Business plans.
How long does a cold start take?
Typically 300-500ms. You can avoid it through scheduled warming or paid "always on" compute.
Can I use all PostgreSQL extensions?
Most popular extensions are available: pgvector, PostGIS, pg_trgm, hstore, uuid-ossp. The full list is available in the Neon documentation.
How to migrate from RDS/Supabase?
- Export data:
pg_dump - Create a Neon project
- Import:
psql < dump.sql - Update the connection string in your application
Is the data safe?
- All connections TLS encrypted
- Data at-rest encrypted (AES-256)
- SOC 2 Type II certified
- GDPR compliant
Summary
Neon is a groundbreaking approach to PostgreSQL in the cloud, offering:
- Serverless with true scale-to-zero - Pay only for usage
- Git-like branching - Isolated environments in seconds
- Lightning-fast cold starts - Ideal for serverless
- Full PG compatibility - No compromises
- Modern integrations - Drizzle, Prisma, Next.js
Neon is ideal for startups, side projects, and modern serverless applications where flexibility and cost are key.