Next.js · Nov 5, 2026 · 8 min read

Type-Safe SQL Queries with Drizzle ORM in Next.js 15

Practical guide to Drizzle ORM in Next.js 15: schema design, relational queries, server components, migrations, and trade-offs versus Prisma. Complete working examples using PostgreSQL and the App Rou

Type-Safe SQL Queries with Drizzle ORM in Next.js 15

Why Drizzle ORM Fits Modern Next.js Applications

Drizzle ORM emphasizes control, performance, and type safety without hiding the SQL underneath. Its query builder produces predictable SQL that maps directly to what you would write by hand, while still giving full TypeScript inference from your table definitions to your components.

At roughly 6kB gzipped, the runtime impact stays minimal compared with Prisma Client which can exceed 40kB and includes a large query engine. Drizzle works especially well with the Next.js App Router because it returns plain JavaScript objects that serialize cleanly into Server Components and React Server Components without extra transformation steps.

Current versions used in this guide are Next.js 15.0.2, Drizzle ORM 0.33.0, and drizzle-kit 0.22.0. The examples target PostgreSQL 16 running on Neon Serverless, but the same patterns apply to Supabase, PlanetScale, or self-hosted Postgres.

Trade-off: you write more explicit code than with Prisma's fluent API. In return you gain zero-overhead execution, easier debugging when queries become complex, and direct access to PostgreSQL features such as JSONB operators, window functions, and custom types.

Project Setup and Required Packages

Initialize the project with the official Next.js CLI:

npx create-next-app@latest drizzle-app \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --yes

Install the core dependencies:

npm install drizzle-orm postgres
npm install -D drizzle-kit

For Neon Serverless Driver (recommended for Vercel deployments):

npm install @neondatabase/serverless

Create a .env.local file with your database URL. Neon provides a connection string that already includes the database name:

DATABASE_URL="postgresql://user:pass@project.neon.tech/dbname?sslmode=require"

Database Connection and Client Configuration

Create a lib/db.ts file that exports a reusable client. Using the Neon serverless driver with Drizzle's pooling:

import { neon } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-http';

const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);

This client works directly inside Server Components and Route Handlers. For high-concurrency workloads you can later switch to the pooled version using neonConfig with WebSocket support, but the HTTP driver suffices for most SaaS MVPs.

Important: never import this client on the client side. The environment variable should stay server-only. Next.js automatically tree-shakes it from client bundles when used inside async Server Components.

Defining a Realistic Schema with Relations

Inside lib/schema.ts define tables using Drizzle's declarative syntax. The following example models users, organizations (for multi-tenancy), and projects:

import { pgTable, serial, text, timestamp, integer, uuid } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

export const organizations = pgTable('organizations', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  createdAt: timestamp('created_at').defaultNow(),
});

export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  orgId: integer('org_id').references(() => organizations.id),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  role: text('role', { enum: ['admin', 'member'] }).notNull(),
  createdAt: timestamp('created_at').defaultNow(),
});

export const projects = pgTable('projects', {
  id: serial('id').primaryKey(),
  orgId: integer('org_id').references(() => organizations.id),
  name: text('name').notNull(),
  status: text('status', { enum: ['draft', 'active', 'archived'] }).default('draft'),
  metadata: text('metadata'), // JSON string for simplicity
  createdAt: timestamp('created_at').defaultNow(),
});

export const usersRelations = relations(users, ({ one }) => ({
  organization: one(organizations, {
    fields: [users.orgId],
    references: [organizations.id],
  }),
}));

export const projectsRelations = relations(projects, ({ one }) => ({
  organization: one(organizations, {
    fields: [projects.orgId],
    references: [organizations.id],
  }),
}));

Notice the explicit foreign keys and separate relation definitions. This gives you full control and clear IntelliSense when writing queries later.

Migrations with Drizzle Kit

Create drizzle.config.ts in the project root:

import type { Config } from 'drizzle-kit';

export default {
  schema: './lib/schema.ts',
  out: './drizzle',
  driver: 'pg',
  dbCredentials: {
    connectionString: process.env.DATABASE_URL!,
  },
} satisfies Config;

Generate a migration:

npx drizzle-kit generate:pg

This creates SQL files inside the drizzle folder. Run them against your database using the Drizzle migrator or a simple script:

import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';

const migrationClient = postgres(process.env.DATABASE_URL!, { max: 1 });
await migrate(drizzle(migrationClient), { migrationsFolder: './drizzle' }); 

Commit the generated migration files. This workflow is reproducible across local, staging, and production environments.

Fetching Data in Server Components

With Drizzle you can query directly inside Server Components. Example page that lists projects for the current organization (using a fake auth context for brevity):

import { db } from '@/lib/db';
import { projects, organizations } from '@/lib/schema';
import { eq } from 'drizzle-orm';

async function ProjectList({ orgId }: { orgId: number }) {
  const result = await db
    .select({
      id: projects.id,
      name: projects.name,
      status: projects.status,
      orgName: organizations.name,
    })
    .from(projects)
    .leftJoin(organizations, eq(projects.orgId, organizations.id))
    .where(eq(projects.orgId, orgId))
    .orderBy(projects.createdAt);

  return (
    <ul>
      {result.map((project) => (
        <li key={project.id}>
          {project.name} — {project.status} ({project.orgName})
        </li>
      ))}
    </ul>
  );
}

export default async function Dashboard() {
  const orgId = 1; // resolved from auth in real app
  return <ProjectList orgId={orgId} />;
}

The returned data is plain objects. No .toJSON() calls or Prisma-specific types leak into your components. Next.js 15 will automatically cache this page as static if you remove dynamic functions.

Mutations Using Server Actions

Server Actions pair naturally with Drizzle. Example action that creates a project and revalidates the dashboard:

'use server';

import { db } from '@/lib/db';
import { projects } from '@/lib/schema';
import { revalidatePath } from 'next/cache';

export async function createProject(formData: FormData) {
  const name = formData.get('name') as string;
  const orgId = Number(formData.get('orgId'));

  await db.insert(projects).values({
    name,
    orgId,
    status: 'draft',
  });

  revalidatePath('/dashboard');
  return { success: true };
}

Because Drizzle returns the inserted row when you add .returning(), you can stream the new record back to the client immediately without a second round-trip.

Relational Queries and Nested Data

Drizzle's relational query API removes the need for complex joins when you want nested objects:

import { db } from '@/lib/db';
import { organizations, projects } from '@/lib/schema';

const orgWithProjects = await db.query.organizations.findFirst({
  where: (orgs, { eq }) => eq(orgs.id, 1),
  with: {
    projects: {
      columns: {
        id: true,
        name: true,
        status: true,
      },
      limit: 10,
      orderBy: (proj, { desc }) => [desc(proj.createdAt)],
    },
  },
});

This produces a single nested JavaScript object instead of an array of flat rows. The generated SQL still uses efficient joins under the hood. Trade-off: the relational API currently supports fewer advanced PostgreSQL features than the core query builder, so you can mix both approaches in the same file.

Transactions and Complex Operations

When multiple statements must succeed or fail together, use transactions:

await db.transaction(async (tx) => {
  const [newOrg] = await tx
    .insert(organizations)
    .values({ name: 'Acme Corp' })
    .returning();

  await tx.insert(projects).values({
    orgId: newOrg.id,
    name: 'Internal Dashboard',
  });
});

Transactions in Drizzle are straightforward async functions. You keep the same query builder syntax inside the callback. For long-running operations, consider smaller transactions to reduce lock times on production databases.

Adding Indexes and Optimizing Query Performance

Define indexes directly in your schema:

import { index } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  // ... columns
}, (table) => ({
  emailIdx: index('email_idx').on(table.email),
  orgRoleIdx: index('org_role_idx').on(table.orgId, table.role),
}));

After adding indexes, run a new migration. In production, use EXPLAIN ANALYZE on slow queries by executing raw SQL through Drizzle:

const explain = await db.execute(sql`EXPLAIN ANALYZE SELECT * FROM projects WHERE org_id = 1`);

This returns the query plan so you can verify index usage without leaving your codebase.

Integration Patterns with Next.js Caching

Combine Drizzle with Next.js 15 Partial Prerendering and cache tags. Tag queries that depend on user data:

import { unstable_cache } from 'next/cache';

const getCachedProjects = unstable_cache(
  async (orgId: number) => {
    return db.query.projects.findMany({
      where: (proj, { eq }) => eq(proj.orgId, orgId),
    });
  },
  ['projects'],
  { revalidate: 3600, tags: ['projects'] }
);

Call revalidateTag('projects') from a webhook or admin action to purge the cache without full page revalidation. This pattern keeps static shells for marketing pages while keeping dashboard data fresh.

Common Pitfalls and How to Avoid Them

1. Forgetting to run migrations after schema changes leads to runtime errors. Always generate and apply migrations before deploying.

2. Using client components to call Drizzle directly breaks server-only database credentials. Keep all database code inside Server Components, Server Actions, or Route Handlers.

3. Overusing .select() with many columns can increase data transfer. Explicitly list only required columns, especially when returning data over the network.

4. JSON columns are stored as text by default. For proper JSONB support change the column type to jsonb('metadata') and import the correct type from Drizzle.

5. In edge runtimes the Neon HTTP driver works; the WebSocket driver does not. Choose the driver based on your deployment target.

Deployment Checklist for Vercel

Connect your Neon database to Vercel using the integration, which automatically adds the DATABASE_URL environment variable to all environments. Add a post-deploy script that runs the latest migrations using a separate vercel.json build step or a dedicated API route triggered once.

Monitor query performance with Neon's dashboard or Vercel Logs. Because Drizzle logs the exact SQL when you enable logger: true in the drizzle instance during development, you can copy and paste slow queries directly into the Postgres console for tuning.

Final trade-off summary: choose Drizzle when you want full SQL transparency, smaller bundle sizes, and direct control over generated queries. Choose Prisma when your team values maximum developer velocity and accepts the larger runtime cost. Most production Next.js applications built in 2024 and 2025 benefit from Drizzle once the team exceeds three developers or when query cost becomes a line item in the monthly budget.

The patterns shown here scale from a weekend prototype to a multi-tenant SaaS serving thousands of daily active users. Start small by replacing one Prisma query at a time; the type-safe migration path is gradual and low risk.

Keep reading

Related Articles