Neon Serverless Postgres + Drizzle ORM: The Modern Full-Stack Database 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.

Database Storage 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:

FeatureDrizzlePrisma
Bundle size~20KB~500KB+
Edge compatible⚠️ (Prisma Accelerate needed)
SQL proximityHigh (write SQL-like)Low (abstracted)
Type safety✅ Excellent✅ Excellent
Schema in code✅ TypeScriptPrisma schema lang
Migrations✅ SQL files✅ Prisma migrate
Raw SQL✅ First-class⚠️ $queryRaw
Performance✅ Zero overheadModerate 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"));
// 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:

  1. Use scale-to-zero (enabled by default) — no idle compute charges
  2. Avoid SELECT * — only select columns you need
  3. Use the connection pooler endpoint for high-concurrency apps
  4. 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:


이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)