Neon Serverless Postgres + Drizzle ORM: The Modern Full-Stack Database Stack
on Postgresql, Neon, Drizzle orm, Serverless, Database, Typescript, Full stack
Neon Serverless Postgres + Drizzle ORM: The Modern Full-Stack Database Stack
If you’re building a modern web application in 2026 and still managing your own Postgres instance, you’re probably wasting ops time you don’t need to spend. Neon has matured into the de facto serverless Postgres platform, and paired with Drizzle ORM, you get a type-safe, zero-ops database experience that feels like it should have existed years ago.
This guide covers the full stack: Neon setup, Drizzle schema, queries, migrations, and production patterns.
Photo by Tobias Fischer on Unsplash
Why Neon?
Neon solves the core problems of serverless databases:
The cold-start problem is dead. Neon’s “scale-to-zero” computes restart in under 500ms in 2026. With their persistent connection pooler (built on PgBouncer), most requests don’t even see a cold start.
Branching is a game-changer. Create a copy-on-write branch of your production database in seconds. Every PR gets its own database branch. No more “dev DB is out of sync with prod.”
main ──────────────────────────── production
└── feature/auth ─────── dev/staging
└── pr/fix-user-query ── CI/CD testing
True Postgres. Not Postgres-compatible. Not a subset. Full Postgres 17 with extensions (pgvector, PostGIS, etc.).
Getting Started with Neon
Setup
# Install Neon CLI
npm install -g neonctl
# Authenticate
neonctl auth
# Create a project
neonctl projects create --name my-app-db --region-id aws-us-east-1
# Get connection string
neonctl connection-string
# postgresql://user:pass@ep-xxx.us-east-1.aws.neon.tech/neondb?sslmode=require
Environment Setup
# .env
DATABASE_URL="postgresql://user:pass@ep-xxx.us-east-1.aws.neon.tech/neondb?sslmode=require"
# For edge environments (Cloudflare Workers, Vercel Edge)
DATABASE_URL_POOLED="postgresql://user:pass@ep-xxx-pooler.us-east-1.aws.neon.tech/neondb?sslmode=require"
Neon provides two endpoints:
- Direct (
ep-xxx.region.aws.neon.tech) — for serverless functions with long-lived connections - Pooled (
ep-xxx-pooler.region...) — for edge functions with many short connections
Drizzle ORM: Why Not Prisma?
The Prisma vs Drizzle debate was settled in 2026. Drizzle won for most new projects:
| Feature | Drizzle | Prisma |
|---|---|---|
| Bundle size | ~20KB | ~500KB+ |
| Edge compatible | ✅ | ⚠️ (Prisma Accelerate needed) |
| SQL proximity | High (write SQL-like) | Low (abstracted) |
| Type safety | ✅ Excellent | ✅ Excellent |
| Schema in code | ✅ TypeScript | Prisma schema lang |
| Migrations | ✅ SQL files | ✅ Prisma migrate |
| Raw SQL | ✅ First-class | ⚠️ $queryRaw |
| Performance | ✅ Zero overhead | Moderate overhead |
Drizzle’s philosophy: write SQL, get types. If you know SQL, Drizzle feels instantly familiar.
Installation
# Install Drizzle + Neon driver
npm install drizzle-orm @neondatabase/serverless
npm install -D drizzle-kit
# Or with Bun (faster)
bun add drizzle-orm @neondatabase/serverless
bun add -d drizzle-kit
Schema Definition
Everything is TypeScript — no separate schema files, no code generation needed:
// src/db/schema.ts
import {
pgTable,
text,
integer,
timestamp,
boolean,
uuid,
jsonb,
index,
uniqueIndex,
pgEnum
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
// Enums
export const userRoleEnum = pgEnum("user_role", ["admin", "user", "viewer"]);
export const postStatusEnum = pgEnum("post_status", ["draft", "published", "archived"]);
// Users table
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull(),
name: text("name").notNull(),
role: userRoleEnum("role").default("user").notNull(),
avatarUrl: text("avatar_url"),
isActive: boolean("is_active").default(true).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
emailIdx: uniqueIndex("users_email_idx").on(table.email),
roleIdx: index("users_role_idx").on(table.role),
}));
// Posts table
export const posts = pgTable("posts", {
id: uuid("id").primaryKey().defaultRandom(),
title: text("title").notNull(),
slug: text("slug").notNull(),
content: text("content"),
status: postStatusEnum("status").default("draft").notNull(),
metadata: jsonb("metadata").$type<{
views: number;
readTime: number;
tags: string[];
}>(),
authorId: uuid("author_id").notNull().references(() => users.id, {
onDelete: "cascade"
}),
publishedAt: timestamp("published_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
slugIdx: uniqueIndex("posts_slug_idx").on(table.slug),
authorIdx: index("posts_author_idx").on(table.authorId),
statusIdx: index("posts_status_idx").on(table.status),
}));
// Comments table
export const comments = pgTable("comments", {
id: uuid("id").primaryKey().defaultRandom(),
content: text("content").notNull(),
postId: uuid("post_id").notNull().references(() => posts.id, { onDelete: "cascade" }),
authorId: uuid("author_id").notNull().references(() => users.id, { onDelete: "cascade" }),
parentId: uuid("parent_id"), // For nested comments
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
// Relations (for type inference in joins)
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
comments: many(comments),
}));
export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
comments: many(comments),
}));
export const commentsRelations = relations(comments, ({ one }) => ({
post: one(posts, {
fields: [comments.postId],
references: [posts.id],
}),
author: one(users, {
fields: [comments.authorId],
references: [users.id],
}),
}));
Database Client Setup
// src/db/index.ts
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";
// HTTP-based driver (best for serverless/edge)
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });
// Export types
export type DB = typeof db;
// For type inference
export type User = typeof schema.users.$inferSelect;
export type NewUser = typeof schema.users.$inferInsert;
export type Post = typeof schema.posts.$inferSelect;
export type NewPost = typeof schema.posts.$inferInsert;
For WebSocket-based connections (better for long-lived Node.js processes):
// src/db/node.ts — for Node.js servers (not edge)
import { Pool } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-serverless";
import * as schema from "./schema";
import ws from "ws";
// Enable WebSocket for serverless driver
import { neonConfig } from "@neondatabase/serverless";
neonConfig.webSocketConstructor = ws;
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool, { schema });
Queries: The Drizzle Way
Basic CRUD
import { db } from "./db";
import { users, posts } from "./db/schema";
import { eq, and, or, like, desc, count, sum, avg, sql } from "drizzle-orm";
// INSERT
const [newUser] = await db.insert(users).values({
email: "alice@example.com",
name: "Alice Johnson",
role: "user",
}).returning();
console.log(newUser.id); // fully typed!
// SELECT with filtering
const activeAdmins = await db
.select()
.from(users)
.where(
and(
eq(users.role, "admin"),
eq(users.isActive, true)
)
)
.orderBy(desc(users.createdAt))
.limit(10);
// UPDATE
const [updatedUser] = await db
.update(users)
.set({
name: "Alice Smith",
updatedAt: new Date()
})
.where(eq(users.id, newUser.id))
.returning();
// DELETE
await db.delete(users).where(eq(users.id, "some-uuid"));
// UPSERT (INSERT ... ON CONFLICT)
const [upserted] = await db
.insert(users)
.values({ email: "alice@example.com", name: "Alice" })
.onConflictDoUpdate({
target: users.email,
set: { name: "Alice", updatedAt: new Date() }
})
.returning();
Joins with Relations
// Using query builder (relational mode)
const postsWithAuthors = await db.query.posts.findMany({
where: eq(posts.status, "published"),
with: {
author: {
columns: {
id: true,
name: true,
avatarUrl: true,
}
},
comments: {
limit: 5,
orderBy: [desc(comments.createdAt)],
with: {
author: {
columns: { id: true, name: true }
}
}
}
},
orderBy: [desc(posts.publishedAt)],
limit: 20,
});
// Type is fully inferred — no type assertions needed!
for (const post of postsWithAuthors) {
console.log(`${post.title} by ${post.author.name}`);
console.log(`${post.comments.length} recent comments`);
}
Aggregations
// Count posts by status
const postStats = await db
.select({
status: posts.status,
count: count(),
})
.from(posts)
.groupBy(posts.status);
// Average read time for published posts
const [stats] = await db
.select({
avgReadTime: avg(sql<number>`(metadata->>'readTime')::int`),
totalViews: sum(sql<number>`(metadata->>'views')::int`),
})
.from(posts)
.where(eq(posts.status, "published"));
Full-Text Search
// Using Postgres's built-in FTS
const searchResults = await db
.select()
.from(posts)
.where(
sql`to_tsvector('english', ${posts.title} || ' ' || coalesce(${posts.content}, ''))
@@ plainto_tsquery('english', ${searchQuery})`
)
.limit(20);
Migrations with Drizzle Kit
// drizzle.config.ts
import type { Config } from "drizzle-kit";
export default {
schema: "./src/db/schema.ts",
out: "./drizzle/migrations",
driver: "pg",
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
} satisfies Config;
# Generate migration from schema changes
npx drizzle-kit generate:pg
# View pending migrations (dry run)
npx drizzle-kit push:pg --verbose --dry-run
# Apply migrations
npx drizzle-kit push:pg
# Introspect existing DB (great for migration)
npx drizzle-kit introspect:pg
Generated migration looks like clean SQL:
-- drizzle/migrations/0001_add_posts_table.sql
CREATE TYPE "post_status" AS ENUM('draft', 'published', 'archived');
CREATE TABLE IF NOT EXISTS "posts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" text NOT NULL,
"slug" text NOT NULL,
"content" text,
"status" "post_status" DEFAULT 'draft' NOT NULL,
"metadata" jsonb,
"author_id" uuid NOT NULL,
"published_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE UNIQUE INDEX "posts_slug_idx" ON "posts" ("slug");
CREATE INDEX "posts_author_idx" ON "posts" ("author_id");
CREATE INDEX "posts_status_idx" ON "posts" ("status");
ALTER TABLE "posts" ADD CONSTRAINT "posts_author_id_users_id_fk"
FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE cascade;
Neon Branching in CI/CD
This is Neon’s killer feature. Every PR branch gets a database branch:
# .github/workflows/pr.yml
name: PR Preview
on:
pull_request:
types: [opened, synchronize]
jobs:
setup-db:
runs-on: ubuntu-latest
outputs:
db_url: $
steps:
- name: Create Neon branch
id: create-branch
uses: neondatabase/create-branch-action@v5
with:
project_id: $
branch_name: pr-$
api_key: $
- name: Run migrations on branch
run: npx drizzle-kit push:pg
env:
DATABASE_URL: $
test:
needs: setup-db
runs-on: ubuntu-latest
env:
DATABASE_URL: $
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test # Tests run against isolated DB branch!
cleanup:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- name: Delete Neon branch
uses: neondatabase/delete-branch-action@v3
with:
project_id: $
branch_name: pr-$
api_key: $
Performance Patterns
Connection Pooling (Critical for Serverless)
// For Next.js App Router / Vercel
// src/db/index.ts
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
// Neon HTTP driver is already connection-pool-friendly
// Each request gets a fresh HTTP connection, pooled by Neon's infrastructure
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);
Batch Queries
// Execute multiple queries in one round-trip
const [usersList, postsCount] = await Promise.all([
db.select().from(users).limit(10),
db.select({ count: count() }).from(posts),
]);
Prepared Statements
import { placeholder } from "drizzle-orm";
// Prepare once, execute many times
const getUserByEmail = db
.select()
.from(users)
.where(eq(users.email, placeholder("email")))
.prepare("get_user_by_email");
// Execute with different values (skips query planning)
const user = await getUserByEmail.execute({ email: "alice@example.com" });
Cost Optimization
Neon’s pricing (2026):
- Free tier: 0.5 GB storage, 1 compute unit, 190 active hours/month
- Launch ($19/mo): 10 GB storage, autoscaling, branching
- Scale ($69/mo): 50 GB, read replicas, IP allow-listing
Tips to minimize cost:
- Use
scale-to-zero(enabled by default) — no idle compute charges - Avoid
SELECT *— only select columns you need - Use the connection pooler endpoint for high-concurrency apps
- Set reasonable connection pool sizes in your app
Full Example: Next.js 15 + Neon + Drizzle API Route
// app/api/posts/route.ts (Next.js App Router)
import { NextResponse } from "next/server";
import { db } from "@/db";
import { posts, users } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
export const runtime = "edge"; // Edge-compatible!
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") ?? "1");
const limit = 20;
const offset = (page - 1) * limit;
const publishedPosts = await db.query.posts.findMany({
where: eq(posts.status, "published"),
with: {
author: {
columns: { id: true, name: true, avatarUrl: true }
}
},
orderBy: [desc(posts.publishedAt)],
limit,
offset,
});
return NextResponse.json({
posts: publishedPosts,
page,
hasMore: publishedPosts.length === limit,
});
}
export async function POST(request: Request) {
const body = await request.json();
const [newPost] = await db
.insert(posts)
.values({
title: body.title,
slug: body.title.toLowerCase().replace(/\s+/g, "-"),
content: body.content,
authorId: body.authorId,
status: "draft",
})
.returning();
return NextResponse.json(newPost, { status: 201 });
}
Conclusion
The Neon + Drizzle stack in 2026 represents the best of modern web development:
- No ops — Neon handles backups, scaling, HA automatically
- Type safety — Drizzle infers types from your schema, no codegen required
- Edge-compatible — HTTP driver works everywhere Postgres didn’t before
- Git-like branching — true database branches for every PR
- Full Postgres — no compromises on features or compatibility
If you’re starting a new project or migrating an existing one, this stack is genuinely worth the learning curve. The type safety alone will save you from countless runtime errors, and Neon’s branching will change how your team thinks about database development workflow.
The future of databases is serverless, branched, and fully typed.
References:
이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)
