
Using Drizzle ORM with Next.js: A Lightweight Type-Safe Alternative
Drizzle ORM has become the go-to choice for Next.js developers who want type safety without a heavy runtime. There is no separate schema language, no generated client to regenerate after every change, and no query engine binary. You describe your tables in TypeScript, and the same file gives you migrations, typed query results, and autocomplete for every column.
That design fits the App Router well. Server Components and Server Actions run on the server, so they can talk to the database directly, and a small, dependency-light ORM keeps cold starts quick on serverless platforms. If you've used Prisma, Drizzle will feel closer to writing SQL: the query builder mirrors SQL clauses almost one to one.
In this guide I'll set up Drizzle with PostgreSQL in a Next.js 16 project, define a schema, run migrations with drizzle-kit, read data in Server Components, write data with Server Actions, and cover the connection details that matter in production.
Why Drizzle Instead of a Heavier ORM
Before installing anything, it helps to know what you're trading. Here is how Drizzle compares to Prisma, the other common choice (covered in Connecting Next.js to a Database with Prisma ORM):
| Drizzle | Prisma | |
|---|---|---|
| Schema definition | TypeScript files | schema.prisma DSL |
| Code generation step | None, types are inferred | prisma generate |
| Query style | SQL-like builder plus a relational API | Object-based API |
| Runtime size | Small, thin wrapper over your driver | Larger client |
| Raw SQL | First-class sql template tag | $queryRaw |
| Migrations | drizzle-kit generate / migrate / push | prisma migrate |
Neither is wrong. Drizzle tends to win when you're comfortable with SQL, want a minimal runtime, or deploy to environments where bundle size and startup time matter. Prisma tends to win when you want the most abstracted API and its tooling ecosystem.
Installing Drizzle
This guide uses PostgreSQL with the postgres driver (often called postgres.js). Drizzle supports many other drivers, and the setup only changes in one file.
npm install drizzle-orm postgres
npm install -D drizzle-kit
drizzle-ormis the runtime library you import in your app.postgresis the actual database driver. Drizzle doesn't open connections itself, it wraps a driver you give it.drizzle-kitis the CLI for migrations and the Drizzle Studio browser. It's a dev dependency.
Add your connection string to .env.local:
# .env.local
DATABASE_URL="postgres://user:password@localhost:5432/myapp"
Defining the Schema in TypeScript
Create a schema file. Tables are plain function calls, and every column builder maps to a Postgres type.
// src/db/schema.ts
import {
pgTable,
serial,
text,
varchar,
integer,
boolean,
timestamp,
index,
} from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
email: varchar("email", { length: 255 }).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(),
slug: varchar("slug", { length: 200 }).notNull().unique(),
body: text("body").notNull(),
published: boolean("published").default(false).notNull(),
authorId: integer("author_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [index("posts_author_idx").on(table.authorId)],
);
export type User = typeof users.$inferSelect;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
A few things worth noticing:
- The first argument to each column builder (
"created_at") is the real column name in the database. The object key (createdAt) is what you use in TypeScript. .references()takes a function so tables can reference each other regardless of declaration order.- The third argument to
pgTablereturns an array of extra definitions such as indexes and composite keys. $inferSelectand$inferInsertgive you row types for free.NewPostmakesid,published, andcreatedAtoptional because they have defaults.
These types are the main reason to choose Drizzle. When you add a column, every query that touches the table is re-checked by the TypeScript compiler, with no generate step in between.
Configuring drizzle-kit and Running Migrations
drizzle-kit needs to know where your schema lives, where to put migration files, and how to connect.
// drizzle.config.ts
import { loadEnvConfig } from "@next/env";
import { defineConfig } from "drizzle-kit";
loadEnvConfig(process.cwd());
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
drizzle-kit runs outside Next.js, so it doesn't read .env.local automatically. loadEnvConfig from @next/env (installed with Next.js) loads your env files with the same rules Next.js uses, so you don't need a separate dotenv setup.
Add scripts to package.json:
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
}
}
There are two workflows, and you should pick one per environment:
generate+migrate:generatediffs your schema against the previous migration snapshot and writes a SQL file to./drizzle.migrateapplies pending files to the database. You commit the SQL files, review them in pull requests, and runmigratein CI or during deploys. Use this for anything shared or in production.push: compares the schema directly with the live database and applies changes, without writing migration files. It's fast for local prototyping, but there's no history to review.
For a new project:
npm run db:generate
npm run db:migrate
Open the generated SQL file before you migrate. Drizzle is good at diffing, but renames are ambiguous (did you rename a column, or drop one and add another?), and generate will ask you interactively when it can't tell.
npm run db:studio launches Drizzle Studio, a local browser UI for viewing and editing rows. It's handy for seeding test data by hand.
Creating the Database Client
Now create the client your app will import. In development, Next.js reloads modules on every edit, and each reload would create a new connection pool if you're not careful. Storing the client on globalThis keeps one pool alive across reloads.
// src/db/index.ts
import "server-only";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const globalForDb = globalThis as unknown as {
pgClient: ReturnType<typeof postgres> | undefined;
};
const client =
globalForDb.pgClient ??
postgres(process.env.DATABASE_URL!, {
max: 10,
});
if (process.env.NODE_ENV !== "production") {
globalForDb.pgClient = client;
}
export const db = drizzle({ client, schema });
The server-only import makes the build fail if a Client Component ever imports this file, which protects your connection string from ending up in the browser bundle. Install it with npm install server-only if your project doesn't already have it. Using the server-only package covers that pattern in depth.
Passing schema enables Drizzle's relational query API (more on that below). If you only use the SQL-like builder, you can leave it out.
Reading Data in Server Components
Server Components can call the database directly. Here's a page that lists published posts with their author names using a join:
// src/db/queries.ts
import "server-only";
import { desc, eq } from "drizzle-orm";
import { db } from "@/db";
import { posts, users } from "@/db/schema";
export async function getPublishedPosts() {
return db
.select({
id: posts.id,
title: posts.title,
slug: posts.slug,
createdAt: posts.createdAt,
authorName: users.name,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.where(eq(posts.published, true))
.orderBy(desc(posts.createdAt))
.limit(20);
}
export async function getPostBySlug(slug: string) {
const [post] = await db
.select()
.from(posts)
.where(eq(posts.slug, slug))
.limit(1);
return post ?? null;
}
The return type of getPublishedPosts is inferred from the select object: an array of { id: number; title: string; slug: string; createdAt: Date; authorName: string }. Selecting only the columns you need also keeps fields like body out of list pages.
// src/app/posts/page.tsx
import { Suspense } from "react";
import Link from "next/link";
import { connection } from "next/server";
import { getPublishedPosts } from "@/db/queries";
async function PostList() {
await connection();
const rows = await getPublishedPosts();
if (rows.length === 0) return <p>No posts yet.</p>;
return (
<ul>
{rows.map((post) => (
<li key={post.id}>
<Link href={`/posts/${post.slug}`}>{post.title}</Link>
<span> by {post.authorName}</span>
</li>
))}
</ul>
);
}
export default function PostsPage() {
return (
<main>
<h1>Posts</h1>
<Suspense fallback={<p>Loading posts...</p>}>
<PostList />
</Suspense>
</main>
);
}
Why connection()? A database query doesn't use any request-time API like cookies() or headers(), so Next.js could otherwise run it once during the build and freeze the result into static HTML. await connection() tells Next.js this component must render per request. Wrapping it in Suspense lets the page shell render immediately while the query runs, and it's what Next.js expects for uncached data when Cache Components is enabled.
A dynamic route reads its params asynchronously in Next.js 16:
// src/app/posts/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPostBySlug } from "@/db/queries";
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post || !post.published) notFound();
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}
The Relational Query API
The SQL-like builder is explicit, but nested data (a user with their posts, a post with its comments) can get verbose with joins. Drizzle's relational API lets you describe relations once and fetch nested results.
Add relation definitions to the schema file:
// src/db/schema.ts (continued)
import { relations } from "drizzle-orm";
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));
Then query with db.query:
const author = await db.query.users.findFirst({
where: (users, { eq }) => eq(users.id, 1),
with: {
posts: {
where: (posts, { eq }) => eq(posts.published, true),
columns: { id: true, title: true, slug: true },
},
},
});
// author?.posts is typed as { id: number; title: string; slug: string }[]
Relations here are a TypeScript-level concept. They don't create foreign keys (that's what .references() does) and they don't change migrations. Drizzle generates a single SQL statement for the nested query rather than one query per level.
Drizzle's 1.0 release line introduces a new relations definition style (defineRelations) with a slightly different where syntax. If you're on that version, check the Drizzle docs for the current form. The core query builder shown elsewhere in this post is the same in both.
Writing Data with Server Actions
Mutations belong in Server Actions. Here is an action that creates a post, validates input, and refreshes the list:
// src/app/posts/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { db } from "@/db";
import { posts } from "@/db/schema";
import { getCurrentUser } from "@/lib/auth";
export type FormState = { error?: string };
function slugify(input: string) {
return input
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
export async function createPost(
_prev: FormState,
formData: FormData,
): Promise<FormState> {
const user = await getCurrentUser();
if (!user) return { error: "You must be signed in." };
const title = String(formData.get("title") ?? "").trim();
const body = String(formData.get("body") ?? "").trim();
if (title.length < 3) return { error: "Title is too short." };
if (body.length === 0) return { error: "Body is required." };
const [created] = await db
.insert(posts)
.values({
title,
body,
slug: slugify(title),
authorId: user.id,
published: formData.get("published") === "on",
})
.onConflictDoNothing({ target: posts.slug })
.returning({ slug: posts.slug });
if (!created) return { error: "A post with that title already exists." };
revalidatePath("/posts");
redirect(`/posts/${created.slug}`);
}
getCurrentUser stands in for whatever auth you use; the important part is that the action checks it, because Server Actions are public endpoints. .returning() is a Postgres feature that hands back the inserted row (or selected columns) in the same round trip. onConflictDoNothing turns a duplicate slug into an empty result instead of a thrown error, so you can show a friendly message.
The form uses useActionState to display errors:
// src/app/posts/new/post-form.tsx
"use client";
import { useActionState } from "react";
import { createPost, type FormState } from "../actions";
const initialState: FormState = {};
export function PostForm() {
const [state, formAction, pending] = useActionState(createPost, initialState);
return (
<form action={formAction}>
<input name="title" placeholder="Title" required />
<textarea name="body" rows={8} required />
<label>
<input type="checkbox" name="published" /> Publish now
</label>
{state.error && <p role="alert">{state.error}</p>}
<button type="submit" disabled={pending}>
{pending ? "Saving..." : "Create post"}
</button>
</form>
);
}
Updates and deletes follow the same shape:
import { and, eq } from "drizzle-orm";
await db
.update(posts)
.set({ published: true })
.where(and(eq(posts.id, postId), eq(posts.authorId, user.id)));
await db
.delete(posts)
.where(and(eq(posts.id, postId), eq(posts.authorId, user.id)));
Including authorId in the where clause is a cheap ownership check: a user can't modify someone else's post even if they send a different postId.
For validation beyond a few fields, pair the action with Zod, as shown in Form Validation in Next.js with Zod and Server Actions.
Transactions
When several writes must succeed or fail together, use db.transaction. Throwing inside the callback (or calling tx.rollback()) rolls everything back.
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { posts, users } from "@/db/schema";
export async function transferPosts(fromUserId: number, toUserId: number) {
return db.transaction(async (tx) => {
const [target] = await tx
.select({ id: users.id })
.from(users)
.where(eq(users.id, toUserId));
if (!target) throw new Error("Target user not found");
return tx
.update(posts)
.set({ authorId: toUserId })
.where(eq(posts.authorId, fromUserId))
.returning({ id: posts.id });
});
}
Use tx, not db, for every query inside the callback. Calls on db run on a different connection and aren't part of the transaction.
Raw SQL When You Need It
Drizzle doesn't try to hide SQL. The sql template tag parameterizes interpolated values, so it's safe from injection, and it can be mixed into builder queries:
import { sql, eq } from "drizzle-orm";
import { db } from "@/db";
import { posts, users } from "@/db/schema";
const counts = await db
.select({
authorName: users.name,
postCount: sql<number>`count(${posts.id})::int`,
})
.from(users)
.leftJoin(posts, eq(posts.authorId, users.id))
.groupBy(users.id);
The generic on sql<number> tells TypeScript the result type. The ::int cast matters for Postgres, because count() returns a bigint, which the driver would otherwise give you as a string.
Caching Query Results
If you've enabled Cache Components (cacheComponents: true in next.config.ts), you can cache a query with "use cache" and tag it, then invalidate the tag after writes:
// src/db/cached.ts
import { cacheLife, cacheTag } from "next/cache";
import { getPublishedPosts } from "@/db/queries";
export async function getCachedPosts() {
"use cache";
cacheLife("hours");
cacheTag("posts");
return getPublishedPosts();
}
In the Server Action, call updateTag("posts") from next/cache after the insert. updateTag expires the entry immediately, so the author sees their new post on the next render. From a Route Handler or webhook, use revalidateTag("posts", "max") instead, which serves stale data while it refreshes. Cached functions don't need connection(), since their whole point is to avoid querying on every request.
Production Connection Notes
Drizzle itself holds no connections; your driver does. A few practical points:
- Pool size: on serverless platforms, each function instance gets its own pool. Keep
maxlow (often 1 to 5) and let a connection pooler do the fan-in. - Transaction-mode poolers: PgBouncer in transaction mode and services like Supabase's pooler don't support prepared statements. With postgres.js, pass
prepare: falsetopostgres(). - HTTP drivers: for Neon, you can skip TCP pools entirely and use its HTTP driver:
// src/db/index.ts (Neon variant)
import "server-only";
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle({ client: sql, schema });
Every query becomes a stateless HTTP request, which suits short-lived functions. The trade-off is that interactive transactions aren't available over HTTP; Neon's WebSocket driver (drizzle-orm/neon-serverless) covers that case.
- Migrations in deploys: run
drizzle-kit migrateas a separate step before the new version starts serving traffic, not inside the app at runtime.
Conclusion
Drizzle gives you a schema written in TypeScript, types inferred straight from it, and a query builder that reads like the SQL it produces. In a Next.js App Router project that means you can query from Server Components, mutate from Server Actions, and let the compiler catch schema mismatches without any code generation step.
Start with the core builder for most queries, add relations when nested reads get tedious, commit your generated migrations, and keep a close eye on connection settings when you deploy to serverless. That covers most of what a production app needs.


