Introduction: Why Database Integration in Next.js Deserves a Rethink
Next.js has fundamentally changed how we build React applications. With the App Router, React Server Components, and Server Actions, you can query databases directly from your components, mutate data without building separate API endpoints, and deploy to edge runtimes that execute closer to your users. It's become genuinely full-stack — and honestly, that shift happened faster than most of us expected.
But here's the thing. Choosing the right ORM to sit between Next.js and your database is a decision that ripples through your entire architecture. It affects cold start times in serverless environments, type safety across your data layer, the migration workflow your team follows, and even which deployment targets you can realistically use. Get it wrong, and you'll spend months fighting your tooling instead of shipping features.
Drizzle ORM has emerged as the go-to choice for Next.js developers, and for good reason. It's TypeScript-first without code generation, produces a runtime footprint of roughly 57KB (compared to Prisma's 2MB+), runs natively on edge runtimes, and gives you SQL-like syntax that doesn't hide what's actually happening at the database level. The v1.0 beta stabilized the migration tooling and introduced Relational Queries v2 — a major upgrade to how you fetch nested data.
In this guide, we'll build a complete database layer for a Next.js App Router application using Drizzle ORM with PostgreSQL. We'll cover everything from project setup and schema design to migrations, querying in Server Components, mutating data with Server Actions, connection management for serverless, and deployment patterns for Vercel. By the end, you'll have a production-ready database integration that takes full advantage of the App Router's server-first architecture.
So, let's dive in.
Setting Up Drizzle ORM in a Next.js Project
Let's start from scratch with a fresh Next.js project and wire up Drizzle ORM with PostgreSQL. We'll use Neon as our serverless Postgres provider since it pairs nicely with Vercel and supports connection pooling out of the box — but the patterns here work with any PostgreSQL provider including Supabase, Railway, or a self-hosted instance.
Install Dependencies
First, install the core packages:
npm install drizzle-orm @neondatabase/serverless
npm install -D drizzle-kit
The drizzle-orm package is the runtime ORM. @neondatabase/serverless is Neon's serverless driver that uses WebSockets instead of traditional TCP connections — this is critical for edge and serverless environments where TCP connections are expensive or flat-out unavailable. drizzle-kit is the CLI tool for migrations, schema introspection, and the Drizzle Studio GUI.
If you're using a traditional PostgreSQL setup (not Neon), install the postgres package instead:
npm install drizzle-orm postgres
npm install -D drizzle-kit
Configure Drizzle Kit
Create a drizzle.config.ts file in the root of your project. This tells Drizzle Kit where your schema lives, where to output migrations, and how to connect to your database:
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
The schema parameter points to your TypeScript schema definition file. The out parameter is where generated SQL migration files and JSON snapshots land. The dialect tells Drizzle Kit which SQL dialect to target — it supports postgresql, mysql, sqlite, and turso.
Environment Variables
Add your database connection string to .env.local:
# .env.local
DATABASE_URL="postgresql://user:password@host/database?sslmode=require"
For Neon specifically, you'll grab this connection string from the Neon dashboard. Make sure sslmode=require is included — Neon requires SSL for all connections.
Project Structure
Here's the directory structure we'll build toward:
src/
├── db/
│ ├── schema.ts # Table definitions
│ ├── relations.ts # Relational query definitions
│ ├── index.ts # Database client instance
│ └── queries/
│ ├── posts.ts # Post-specific queries
│ └── users.ts # User-specific queries
├── app/
│ ├── page.tsx # Server Component using DB
│ └── actions/
│ └── posts.ts # Server Actions for mutations
drizzle/ # Generated migrations
drizzle.config.ts # Drizzle Kit config
Keeping the database layer in src/db/ separated from your app routes gives you clean imports and makes the data layer testable independently of Next.js. I've found this separation pays for itself pretty quickly as your project grows.
Defining Your Schema
Drizzle's schema definition is pure TypeScript. There's no separate schema language, no code generation step, and no npx prisma generate to remember after every change. You define your tables with functions, and the resulting objects serve double duty as both the runtime query targets and the TypeScript type source.
Basic Table Definitions
Let's define a schema for a blog application with users, posts, categories, and tags:
// src/db/schema.ts
import {
pgTable,
serial,
varchar,
text,
timestamp,
integer,
boolean,
primaryKey,
} from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
email: varchar("email", { length: 255 }).notNull().unique(),
avatarUrl: text("avatar_url"),
role: varchar("role", { length: 50 }).notNull().default("author"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const categories = pgTable("categories", {
id: serial("id").primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
slug: varchar("slug", { length: 255 }).notNull().unique(),
description: text("description"),
});
export const posts = pgTable("posts", {
id: serial("id").primaryKey(),
title: varchar("title", { length: 500 }).notNull(),
slug: varchar("slug", { length: 500 }).notNull().unique(),
content: text("content").notNull(),
excerpt: text("excerpt"),
published: boolean("published").notNull().default(false),
authorId: integer("author_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
categoryId: integer("category_id").references(() => categories.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").notNull().defaultNow(),
publishedAt: timestamp("published_at"),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const tags = pgTable("tags", {
id: serial("id").primaryKey(),
name: varchar("name", { length: 100 }).notNull().unique(),
slug: varchar("slug", { length: 100 }).notNull().unique(),
});
export const postTags = pgTable(
"post_tags",
{
postId: integer("post_id")
.notNull()
.references(() => posts.id, { onDelete: "cascade" }),
tagId: integer("tag_id")
.notNull()
.references(() => tags.id, { onDelete: "cascade" }),
},
(t) => [primaryKey({ columns: [t.postId, t.tagId] })]
);
A few things worth noticing here. The references() calls define foreign keys directly in the schema — Drizzle generates the correct SQL constraints for you. The onDelete cascading behavior is explicit, which I really appreciate. And the composite primary key on postTags is defined using the table callback pattern.
Extracting TypeScript Types
One of Drizzle's biggest advantages is that your schema is your type system. You can extract insert and select types directly from the table definitions:
// src/db/schema.ts (continued)
import { InferSelectModel, InferInsertModel } from "drizzle-orm";
export type User = InferSelectModel<typeof users>;
export type NewUser = InferInsertModel<typeof users>;
export type Post = InferSelectModel<typeof posts>;
export type NewPost = InferInsertModel<typeof posts>;
export type Category = InferSelectModel<typeof categories>;
export type NewCategory = InferInsertModel<typeof categories>;
The InferSelectModel type includes all columns. The InferInsertModel type makes columns with defaults (like id and createdAt) optional, since the database generates them. This means your insert functions get precise typing automatically — no hand-written interfaces that slowly drift out of sync with your actual schema.
Defining Relations for Relational Queries v2
Drizzle's Relational Queries v2 is honestly a game-changer. It lets you fetch deeply nested data using a clean, declarative API — and Drizzle compiles it down to exactly one SQL query. But to use it, you need to define relations explicitly:
// src/db/relations.ts
import { relations } from "drizzle-orm";
import { users, posts, categories, tags, postTags } from "./schema";
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
category: one(categories, {
fields: [posts.categoryId],
references: [categories.id],
}),
postTags: many(postTags),
}));
export const categoriesRelations = relations(categories, ({ many }) => ({
posts: many(posts),
}));
export const tagsRelations = relations(tags, ({ many }) => ({
postTags: many(postTags),
}));
export const postTagsRelations = relations(postTags, ({ one }) => ({
post: one(posts, {
fields: [postTags.postId],
references: [posts.id],
}),
tag: one(tags, {
fields: [postTags.tagId],
references: [tags.id],
}),
}));
These relation definitions are separate from the SQL-level foreign keys. Foreign keys enforce referential integrity at the database level; relations define the query API surface at the application level. You need both for a complete setup — it's a common point of confusion for newcomers.
The Database Client
This is where things get interesting with Next.js. Connection management in a serverless environment is fundamentally different from a traditional server. Each function invocation might need its own connection, and you can't rely on a persistent connection pool the way you would in Express or Fastify.
For Neon (Serverless PostgreSQL)
// src/db/index.ts
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";
import * as relations from "./relations";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, {
schema: { ...schema, ...relations },
});
Neon's serverless driver uses HTTP-based queries by default. Each query is a stateless HTTP request — there's no persistent connection to manage. This is ideal for serverless and edge environments because you never hit connection limits, and there's zero overhead for connection setup.
If you need transactions (which require a persistent connection), use the WebSocket-based driver instead:
// src/db/index.ts (with transaction support)
import { Pool } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-serverless";
import * as schema from "./schema";
import * as relations from "./relations";
const pool = new Pool({ connectionString: process.env.DATABASE_URL! });
export const db = drizzle(pool, {
schema: { ...schema, ...relations },
});
For Traditional PostgreSQL
If you're running PostgreSQL on Railway, a VPS, or any non-serverless provider, use the postgres (or pg) driver with connection pooling:
// src/db/index.ts
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import * as schema from "./schema";
import * as relations from "./relations";
const connectionString = process.env.DATABASE_URL!;
// For serverless environments: disable prefetch
const client = postgres(connectionString, { prepare: false });
export const db = drizzle(client, {
schema: { ...schema, ...relations },
});
The prepare: false option is important for serverless. Prepared statements assume a persistent connection, and in serverless environments each invocation might get a fresh connection where those prepared statements simply don't exist anymore.
Running Migrations
Drizzle Kit gives you two approaches to keeping your database schema in sync: generate + migrate for production, and push for development.
The Generate + Migrate Workflow
This is what you'll use in production. Run drizzle-kit generate to create SQL migration files from your schema changes, then drizzle-kit migrate to apply them:
# Generate migration files from schema changes
npx drizzle-kit generate
# Apply migrations to the database
npx drizzle-kit migrate
Each generate command compares your current schema against the previous snapshot and produces a timestamped SQL migration file in the drizzle/ directory. These files are deterministic — you commit them to version control and run them sequentially in any environment.
Add these scripts to your package.json for convenience:
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
}
}
The Push Workflow (Development)
During development, drizzle-kit push applies schema changes directly to the database without generating migration files. It's faster for rapid iteration:
npx drizzle-kit push
Push reads your schema, introspects the database, calculates the diff, and applies the changes immediately. Perfect for prototyping — but don't use it in production, because you lose the audit trail of migration files.
Drizzle Studio
Drizzle Kit includes a built-in database GUI that runs in your browser:
npx drizzle-kit studio
This launches a web interface at https://local.drizzle.studio where you can browse tables, view data, run queries, and edit records. It's incredibly useful for debugging during development — way faster than writing throwaway scripts to inspect your data.
Querying Data in Server Components
This is where the Next.js App Router and Drizzle really shine together. Since Server Components execute on the server by default, you can query your database directly inside your components without any API layer. No middleware, no REST endpoints, no GraphQL resolvers — just a function call.
Basic Queries
// app/posts/page.tsx
import { db } from "@/db";
import { posts } from "@/db/schema";
import { desc, eq } from "drizzle-orm";
export default async function PostsPage() {
const allPosts = await db
.select()
.from(posts)
.where(eq(posts.published, true))
.orderBy(desc(posts.publishedAt))
.limit(20);
return (
<div>
<h1>Published Posts</h1>
{allPosts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
There's no fetch(), no API route, no loading state management. The query runs during server rendering, and the HTML is sent to the client with the data already baked in. This is the server-first model that the App Router was designed for.
Relational Queries
Using Drizzle's Relational Queries API, you can fetch nested data in a single query. This is far more efficient than making multiple sequential queries:
// app/posts/[slug]/page.tsx
import { db } from "@/db";
import { posts } from "@/db/schema";
import { eq } from "drizzle-orm";
import { notFound } from "next/navigation";
interface Props {
params: Promise<{ slug: string }>;
}
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await db.query.posts.findFirst({
where: eq(posts.slug, slug),
with: {
author: true,
category: true,
postTags: {
with: {
tag: true,
},
},
},
});
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<p>By {post.author.name}</p>
{post.category && <span>{post.category.name}</span>}
<div>
{post.postTags.map(({ tag }) => (
<span key={tag.id}>{tag.name}</span>
))}
</div>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
The with clause fetches the post along with its author, category, and tags — all in a single SQL query. Drizzle compiles this into an efficient query with the appropriate joins, so you're not dealing with the N+1 problem that plagues naive ORM usage.
Filtering with Relational Queries v2
Relational Queries v2 introduced cross-table filtering, which lets you filter results based on related data. This is one of my favorite features:
// Get all users who have at least one published post
const activeAuthors = await db.query.users.findMany({
where: (users, { exists, and, eq }) =>
exists(
db
.select()
.from(posts)
.where(and(eq(posts.authorId, users.id), eq(posts.published, true)))
),
with: {
posts: {
where: eq(posts.published, true),
limit: 5,
orderBy: desc(posts.publishedAt),
},
},
});
This gives you the expressiveness of raw SQL with the type safety and composability of a query builder. Every part of this query is fully typed — the IDE knows exactly what fields are available on each table and relationship.
Mutating Data with Server Actions
Server Actions pair naturally with Drizzle for data mutations. Instead of building separate API routes for every CRUD operation, you define typed functions that run on the server and can be called directly from forms or client components.
Creating a Post
// app/actions/posts.ts
"use server";
import { db } from "@/db";
import { posts, postTags, tags } from "@/db/schema";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { eq } from "drizzle-orm";
const createPostSchema = z.object({
title: z.string().min(1).max(500),
slug: z.string().min(1).max(500).regex(/^[a-z0-9-]+$/),
content: z.string().min(1),
excerpt: z.string().optional(),
categoryId: z.coerce.number().optional(),
tagIds: z.array(z.coerce.number()).optional(),
});
export async function createPost(formData: FormData) {
const raw = {
title: formData.get("title"),
slug: formData.get("slug"),
content: formData.get("content"),
excerpt: formData.get("excerpt"),
categoryId: formData.get("categoryId"),
tagIds: formData.getAll("tagIds"),
};
const validated = createPostSchema.parse(raw);
const [newPost] = await db
.insert(posts)
.values({
title: validated.title,
slug: validated.slug,
content: validated.content,
excerpt: validated.excerpt || null,
categoryId: validated.categoryId || null,
authorId: 1, // Replace with actual auth user ID
})
.returning();
// Link tags if provided
if (validated.tagIds && validated.tagIds.length > 0) {
await db.insert(postTags).values(
validated.tagIds.map((tagId) => ({
postId: newPost.id,
tagId,
}))
);
}
revalidatePath("/posts");
redirect(`/posts/${newPost.slug}`);
}
Key patterns here: Zod validation on the server side ensures data integrity regardless of what the client sends. The .returning() clause gets the inserted row back so we have the generated id for linking tags. And revalidatePath ensures the posts listing page refreshes with the new data.
Updating and Deleting
// app/actions/posts.ts (continued)
export async function updatePost(postId: number, formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
await db
.update(posts)
.set({
title,
content,
updatedAt: new Date(),
})
.where(eq(posts.id, postId));
revalidatePath("/posts");
revalidatePath(`/posts/${formData.get("slug")}`);
}
export async function deletePost(postId: number) {
await db.delete(posts).where(eq(posts.id, postId));
revalidatePath("/posts");
redirect("/posts");
}
export async function togglePublish(postId: number) {
const post = await db.query.posts.findFirst({
where: eq(posts.id, postId),
columns: { published: true },
});
if (!post) throw new Error("Post not found");
await db
.update(posts)
.set({
published: !post.published,
publishedAt: !post.published ? new Date() : null,
updatedAt: new Date(),
})
.where(eq(posts.id, postId));
revalidatePath("/posts");
}
Because we defined onDelete: "cascade" on the postTags foreign key, deleting a post automatically removes its tag associations. No manual cleanup needed — the database handles it for you.
Using Server Actions in Client Components
// components/DeleteButton.tsx
"use client";
import { deletePost } from "@/app/actions/posts";
import { useTransition } from "react";
export function DeleteButton({ postId }: { postId: number }) {
const [isPending, startTransition] = useTransition();
return (
<button
disabled={isPending}
onClick={() => {
if (confirm("Delete this post?")) {
startTransition(() => deletePost(postId));
}
}}
>
{isPending ? "Deleting..." : "Delete"}
</button>
);
}
The useTransition hook gives you a pending state while the server action executes, letting you show loading UI without any additional state management. Simple and effective.
Transactions
Sometimes you need multiple database operations to succeed or fail together. Drizzle supports transactions with a clean callback API:
// Create a post with tags in a single transaction
export async function createPostWithTags(
postData: NewPost,
tagNames: string[]
) {
return await db.transaction(async (tx) => {
// Insert the post
const [newPost] = await tx
.insert(posts)
.values(postData)
.returning();
// Upsert tags and link them
for (const tagName of tagNames) {
const slug = tagName.toLowerCase().replace(/\s+/g, "-");
const [tag] = await tx
.insert(tags)
.values({ name: tagName, slug })
.onConflictDoNothing()
.returning();
const existingTag =
tag ??
(await tx.query.tags.findFirst({
where: eq(tags.slug, slug),
}));
if (existingTag) {
await tx
.insert(postTags)
.values({ postId: newPost.id, tagId: existingTag.id });
}
}
return newPost;
});
}
If any query within the transaction throws an error, the entire transaction rolls back. This guarantees you never end up with a post that's partially created — either everything succeeds, or nothing changes. That's exactly the kind of safety net you want in production.
One thing to keep in mind: transactions require a persistent connection. If you're using Neon's HTTP driver, you'll need to switch to the WebSocket-based pool driver (shown earlier) for transaction support.
Connection Management for Serverless
Connection management is probably the single most misunderstood aspect of using databases with Next.js in serverless environments. Let's get it right.
The Problem
In a traditional Node.js server, you create a connection pool at startup and reuse it for the lifetime of the process. In serverless, there is no persistent process. Each function invocation might create a new connection, and under load you can easily exhaust your database's connection limit. I've seen teams hit this wall in production and it's not fun to debug at 2 AM.
Solution 1: Neon's Serverless Driver (Recommended)
Neon's HTTP-based driver sidesteps the problem entirely. Each query is a stateless HTTP request routed through Neon's proxy layer, which manages the actual PostgreSQL connections. You never hit connection limits because you're not holding open connections:
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
// No connection pool — each query is an HTTP request
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);
The trade-off is that HTTP-based queries add a small amount of latency per query compared to a persistent connection. For most applications, this is negligible. If you need maximum throughput, use the WebSocket pool driver and manage connection limits at the Neon level.
Solution 2: Connection Pooling with Supabase
Supabase runs PgBouncer in front of PostgreSQL, supporting up to 10,000 client connections through connection pooling. Use the pooler connection string (port 6543) instead of the direct connection string:
// Use the Supabase pooler URL for serverless
const connectionString = process.env.DATABASE_URL!; // port 6543
const client = postgres(connectionString, { prepare: false });
export const db = drizzle(client);
The prepare: false flag is critical when using PgBouncer in transaction mode, because prepared statements don't survive across different backend connections.
Solution 3: Global Instance Pattern
In Next.js development mode, hot module reloading creates new module instances on every file change, which can leak connections. Use the global pattern to prevent this:
// src/db/index.ts
import { neon } from "@neondatabase/serverless";
import { drizzle, NeonHttpDatabase } from "drizzle-orm/neon-http";
import * as schema from "./schema";
import * as relations from "./relations";
const globalForDb = globalThis as unknown as {
db: NeonHttpDatabase<typeof schema & typeof relations> | undefined;
};
export const db =
globalForDb.db ??
drizzle(neon(process.env.DATABASE_URL!), {
schema: { ...schema, ...relations },
});
if (process.env.NODE_ENV !== "production") {
globalForDb.db = db;
}
This ensures you only create one Drizzle instance during development, no matter how many times the module reloads. It's a small thing, but it'll save you from some confusing "too many connections" errors.
Caching and Performance Patterns
The App Router's caching story interacts directly with your database queries. Understanding these patterns is essential for good performance.
Using React's cache() for Request Deduplication
If multiple Server Components on the same page need the same data, React's cache() function deduplicates the queries within a single request:
// src/db/queries/posts.ts
import { cache } from "react";
import { db } from "@/db";
import { posts } from "@/db/schema";
import { eq } from "drizzle-orm";
export const getPostBySlug = cache(async (slug: string) => {
return db.query.posts.findFirst({
where: eq(posts.slug, slug),
with: {
author: true,
category: true,
postTags: { with: { tag: true } },
},
});
});
export const getPublishedPosts = cache(async () => {
return db.query.posts.findMany({
where: eq(posts.published, true),
orderBy: (posts, { desc }) => [desc(posts.publishedAt)],
with: { author: { columns: { name: true, avatarUrl: true } } },
});
});
If getPostBySlug("my-post") is called in three different Server Components during the same render, the query only executes once. The result is shared across all callers. Pretty neat.
Static Generation with Drizzle
For content-heavy pages, you can combine Drizzle queries with Next.js static generation:
// app/posts/[slug]/page.tsx
import { db } from "@/db";
import { posts } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function generateStaticParams() {
const allPosts = await db
.select({ slug: posts.slug })
.from(posts)
.where(eq(posts.published, true));
return allPosts.map((post) => ({ slug: post.slug }));
}
// The page component fetches the full post at build time
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// ... fetch and render
}
At build time, generateStaticParams queries all published post slugs, and Next.js pre-renders each page. Combined with Incremental Static Regeneration (ISR), you get the performance of static pages with the freshness of dynamic data.
Using unstable_cache for Data Caching
For queries that should be cached across multiple requests (not just within a single render), use Next.js's unstable_cache:
import { unstable_cache } from "next/cache";
import { db } from "@/db";
import { categories } from "@/db/schema";
export const getCachedCategories = unstable_cache(
async () => {
return db.select().from(categories);
},
["categories"],
{ revalidate: 3600, tags: ["categories"] }
);
This caches the categories query result for one hour. You can also invalidate it on demand using revalidateTag("categories") in a Server Action when categories change.
Edge Runtime Compatibility
One of Drizzle's strongest advantages is its edge runtime compatibility. Unlike Prisma, which requires a large binary engine, Drizzle runs anywhere JavaScript runs — including Vercel Edge Functions, Cloudflare Workers, and Deno Deploy.
To use Drizzle in edge-runtime route handlers or middleware:
// app/api/posts/route.ts
import { db } from "@/db";
import { posts } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { NextResponse } from "next/server";
export const runtime = "edge";
export async function GET(request: Request) {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get("page") ?? "1");
const limit = 10;
const offset = (page - 1) * limit;
const results = await db
.select()
.from(posts)
.where(eq(posts.published, true))
.orderBy(desc(posts.publishedAt))
.limit(limit)
.offset(offset);
return NextResponse.json(results);
}
This route handler runs on the edge — closer to your users, with sub-millisecond cold starts. The only requirement is that your database driver supports edge environments. Neon's serverless driver and Turso's libSQL driver both work out of the box.
Deployment Patterns for Vercel
When deploying your Next.js + Drizzle application to Vercel, there are a few patterns worth getting right from the start.
Running Migrations in CI/CD
Don't run migrations during the Vercel build step. Instead, run them in your CI pipeline before deployment:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
migrate-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx drizzle-kit migrate
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: "--prod"
This ensures migrations run once before deployment, not on every serverless function cold start.
Environment Variables on Vercel
Set your DATABASE_URL in Vercel's environment variables dashboard. If you're using Neon, Vercel has a native integration that provisions and configures the database automatically — just connect the Neon integration from the Vercel dashboard and the environment variable is set for you. It takes about 30 seconds.
Preview Deployments with Database Branching
One of the most powerful patterns for development workflows is pairing Vercel preview deployments with Neon's database branching. Each pull request gets its own preview deployment and its own database branch — a full copy of your schema with isolated data:
# In your CI, create a Neon branch for the preview
neonctl branches create --name preview-${{ github.event.pull_request.number }}
# Set the branch URL as the DATABASE_URL for the preview deployment
vercel env add DATABASE_URL preview --value "$BRANCH_DATABASE_URL"
This means reviewers can test database changes without risking production data. When the PR is merged or closed, delete the branch to clean up. It's a workflow that, once you've tried it, you won't want to go back.
Testing Your Database Layer
A solid testing strategy for your database layer should include both unit tests for query logic and integration tests against a real database.
Integration Tests with a Test Database
// __tests__/db/posts.test.ts
import { db } from "@/db";
import { posts, users } from "@/db/schema";
import { eq } from "drizzle-orm";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
describe("Posts queries", () => {
let testUserId: number;
beforeAll(async () => {
// Create a test user
const [user] = await db
.insert(users)
.values({
name: "Test User",
email: `test-${Date.now()}@example.com`,
})
.returning();
testUserId = user.id;
});
afterEach(async () => {
// Clean up posts after each test
await db.delete(posts).where(eq(posts.authorId, testUserId));
});
it("should create and retrieve a post", async () => {
const [created] = await db
.insert(posts)
.values({
title: "Test Post",
slug: `test-post-${Date.now()}`,
content: "Test content",
authorId: testUserId,
})
.returning();
const fetched = await db.query.posts.findFirst({
where: eq(posts.id, created.id),
});
expect(fetched).toBeDefined();
expect(fetched!.title).toBe("Test Post");
});
});
Use a separate test database (Neon branches are great for this) and configure DATABASE_URL in your test environment. This gives you real SQL execution without affecting development or production data.
Common Pitfalls and How to Avoid Them
After working with Drizzle and Next.js in production, here are the mistakes that trip people up most often:
- Importing database code in Client Components: If you accidentally import your
dbinstance or schema in a file marked"use client", your database credentials will be bundled into the client-side JavaScript. Use theserver-onlypackage to prevent this — addimport "server-only"at the top of yoursrc/db/index.tsfile. Seriously, do this first. - Forgetting
prepare: falsewith connection poolers: PgBouncer and similar poolers in transaction mode don't support prepared statements. Without this flag, you'll get cryptic errors about missing prepared statements that can be really confusing to track down. - N+1 queries in nested components: If you have a list component that renders child components, and each child fetches its own data, you get N+1 queries. Use Drizzle's relational queries to fetch all the data in one query at the parent level, then pass it down as props.
- Not using
returning(): After inserts and updates, always chain.returning()if you need the resulting data. Without it, Drizzle returns nothing, and you'd have to make a separate query to get the inserted record. - Leaking connections in development: Use the global instance pattern shown earlier to prevent hot module reloading from creating new connections on every file save.
Wrapping Up
We've covered the full journey from installing Drizzle ORM to deploying a production-ready database integration with Next.js. Here are the key architectural decisions to remember:
- Schema as code: Drizzle's TypeScript schema definitions give you type safety without code generation. Change your schema, and your types update instantly.
- Relational Queries v2: Fetch deeply nested data in a single SQL query using a declarative API. No more N+1 headaches.
- Server Components for reads: Query your database directly in Server Components. No API layer needed for read operations.
- Server Actions for writes: Mutate data with typed, validated server functions that integrate seamlessly with forms and client components.
- Serverless-first connections: Use Neon's HTTP driver or connection pooling to avoid exhausting database connections in serverless environments.
- Edge compatibility: Drizzle's 57KB runtime runs anywhere, giving you the option to deploy route handlers and middleware to edge locations.
- Migration workflow: Use
generate + migratefor production deployments andpushfor rapid development iteration.
The combination of Next.js App Router and Drizzle ORM represents the current state of the art for full-stack TypeScript applications. The server-first architecture eliminates entire categories of complexity — no REST APIs for simple CRUD, no client-side data fetching libraries for server-rendered pages, no separate type definitions for your database models. It's all TypeScript, all the way down.
If you're starting a new Next.js project today, Drizzle ORM is the database layer I'd reach for. And if you're migrating from Prisma, the switch is more straightforward than you might think — Drizzle Kit can introspect your existing database and generate the schema for you with drizzle-kit pull.