DEV Community

1xApi
1xApi

Posted on • Originally published at 1xapi.com

How to Build a Type-Safe REST API with Drizzle ORM in Node.js (2026 Guide)

Building a production-ready REST API requires a database layer that is fast, type-safe, and easy to maintain. In 2026, Drizzle ORM has emerged as the go-to choice for TypeScript developers who want the power of SQL without sacrificing type safety or performance. With v0.45.1 stable and v1.0.0 beta actively in development, Drizzle is maturing fast.

This guide walks you through building a complete, type-safe REST API with Drizzle ORM, Node.js, and PostgreSQL — covering schema definition, migrations, relations, and real-world query patterns.

Why Drizzle ORM in 2026?

Before we build, let's understand why Drizzle has overtaken Prisma and TypeORM as the preferred ORM for Node.js APIs:

  • ~7.4kb minified+gzipped — zero heavy runtime footprint
  • Zero dependencies — nothing to break
  • SQL-first — write TypeScript that feels like SQL, not magic
  • Full TypeScript inference — your schema IS your types
  • Works everywhere — PostgreSQL, MySQL, SQLite, serverless (Neon, Turso, Cloudflare D1)
  • Drizzle Studio — built-in browser-based DB GUI (npx drizzle-kit studio)

Unlike Prisma which generates a massive client and hides SQL behind abstractions, Drizzle keeps you in control while still giving you complete compile-time type safety.

Prerequisites

You'll need:

  • Node.js 22+ (or Bun 1.x)
  • PostgreSQL 16+ running locally or a connection string (Neon, Supabase, etc.)
  • TypeScript knowledge

Step 1: Initialize the Project

mkdir drizzle-api && cd drizzle-api
npm init -y
npm install drizzle-orm postgres
npm install -D drizzle-kit typescript tsx @types/node
Enter fullscreen mode Exit fullscreen mode

Initialize TypeScript:

npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext --strict true --outDir dist
Enter fullscreen mode Exit fullscreen mode

Your package.json scripts:

{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio"
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Define Your Drizzle Schema

Drizzle schemas are just TypeScript — no separate .prisma files. Create src/db/schema.ts:

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

// Users table
export const users = pgTable(
  'users',
  {
    id: serial('id').primaryKey(),
    email: varchar('email', { length: 255 }).notNull().unique(),
    name: varchar('name', { length: 100 }).notNull(),
    isActive: boolean('is_active').default(true).notNull(),
    createdAt: timestamp('created_at').defaultNow().notNull(),
    updatedAt: timestamp('updated_at').defaultNow().notNull(),
  },
  (table) => [
    index('users_email_idx').on(table.email),
    index('users_created_at_idx').on(table.createdAt),
  ]
);

// Posts table
export const posts = pgTable(
  'posts',
  {
    id: serial('id').primaryKey(),
    title: varchar('title', { length: 255 }).notNull(),
    content: text('content').notNull(),
    authorId: integer('author_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    published: boolean('published').default(false).notNull(),
    createdAt: timestamp('created_at').defaultNow().notNull(),
  },
  (table) => [
    index('posts_author_idx').on(table.authorId),
    index('posts_published_idx').on(table.published),
  ]
);

// Comments table
export const comments = pgTable('comments', {
  id: serial('id').primaryKey(),
  content: text('content').notNull(),
  postId: integer('post_id')
    .notNull()
    .references(() => posts.id, { onDelete: 'cascade' }),
  authorId: integer('author_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});
Enter fullscreen mode Exit fullscreen mode

Key insight: Notice how indexes are defined inline with the schema. Drizzle's pgTable accepts a second argument for constraints and indexes — keeping everything in one place.

Step 3: Define Relations

Relations in Drizzle are separate from the schema but crucial for typed relational queries. Add to schema.ts:

// Relations (for Drizzle relational queries)
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],
  }),
}));
Enter fullscreen mode Exit fullscreen mode

Step 4: Database Client Setup

Create src/db/client.ts:

import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema.js';

const connectionString = process.env.DATABASE_URL!;

// For migrations: max 1 connection
const migrationClient = postgres(connectionString, { max: 1 });

// For queries: connection pool (default 10)
const queryClient = postgres(connectionString);

export const db = drizzle(queryClient, { schema });

// Export migration client separately
export const migrationDb = drizzle(migrationClient);
Enter fullscreen mode Exit fullscreen mode

And the Drizzle config drizzle.config.ts at the root:

import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  schema: './src/db/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  verbose: true,
  strict: true,
});
Enter fullscreen mode Exit fullscreen mode

Step 5: Generate and Run Migrations

# Generate SQL migration files from your schema
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb npm run db:generate

# Apply migrations to the database
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb npm run db:migrate
Enter fullscreen mode Exit fullscreen mode

Drizzle Kit creates a drizzle/ folder with timestamped SQL files:

-- drizzle/0000_initial.sql (generated automatically)
CREATE TABLE "users" (
  "id" serial PRIMARY KEY NOT NULL,
  "email" varchar(255) NOT NULL,
  "name" varchar(100) NOT NULL,
  "is_active" boolean DEFAULT true NOT NULL,
  "created_at" timestamp DEFAULT now() NOT NULL,
  "updated_at" timestamp DEFAULT now() NOT NULL,
  CONSTRAINT "users_email_unique" UNIQUE("email")
);
Enter fullscreen mode Exit fullscreen mode

When you change your schema (add a column, rename a table), just run db:generate again — Drizzle detects the diff and generates a new migration file.

Step 6: Build the REST API Routes

Create src/routes/users.ts:

import { Router, Request, Response } from 'express';
import { db } from '../db/client.js';
import { users, posts } from '../db/schema.js';
import { eq, desc, count } from 'drizzle-orm';

const router = Router();

// GET /users — list all users with post count
router.get('/', async (req: Request, res: Response) => {
  try {
    const result = await db
      .select({
        id: users.id,
        email: users.email,
        name: users.name,
        isActive: users.isActive,
        createdAt: users.createdAt,
        postCount: count(posts.id),
      })
      .from(users)
      .leftJoin(posts, eq(posts.authorId, users.id))
      .groupBy(users.id)
      .orderBy(desc(users.createdAt));

    res.json({ data: result, total: result.length });
  } catch (err) {
    res.status(500).json({ error: 'Failed to fetch users' });
  }
});

// GET /users/:id — single user with their posts (relational query)
router.get('/:id', async (req: Request, res: Response) => {
  const id = parseInt(req.params.id);

  const result = await db.query.users.findFirst({
    where: eq(users.id, id),
    with: {
      posts: {
        where: eq(posts.published, true),
        orderBy: [desc(posts.createdAt)],
        limit: 10,
      },
    },
  });

  if (!result) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.json({ data: result });
});

// POST /users — create a user
router.post('/', async (req: Request, res: Response) => {
  const { email, name } = req.body;

  try {
    const [newUser] = await db
      .insert(users)
      .values({ email, name })
      .returning();

    res.status(201).json({ data: newUser });
  } catch (err: any) {
    if (err.code === '23505') {
      // PostgreSQL unique violation
      return res.status(409).json({ error: 'Email already exists' });
    }
    res.status(500).json({ error: 'Failed to create user' });
  }
});

// PATCH /users/:id — update a user
router.patch('/:id', async (req: Request, res: Response) => {
  const id = parseInt(req.params.id);
  const { name, isActive } = req.body;

  const [updated] = await db
    .update(users)
    .set({
      ...(name && { name }),
      ...(isActive !== undefined && { isActive }),
      updatedAt: new Date(),
    })
    .where(eq(users.id, id))
    .returning();

  if (!updated) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.json({ data: updated });
});

// DELETE /users/:id — soft delete (set isActive = false)
router.delete('/:id', async (req: Request, res: Response) => {
  const id = parseInt(req.params.id);

  await db
    .update(users)
    .set({ isActive: false, updatedAt: new Date() })
    .where(eq(users.id, id));

  res.status(204).send();
});

export default router;
Enter fullscreen mode Exit fullscreen mode

Step 7: Advanced Query Patterns

Pagination with Cursor-Based Approach

import { gt, and } from 'drizzle-orm';

// Cursor-based pagination (better than OFFSET for large datasets)
async function getPaginatedPosts(cursor?: number, limit = 20) {
  return db
    .select()
    .from(posts)
    .where(
      and(
        eq(posts.published, true),
        cursor ? gt(posts.id, cursor) : undefined
      )
    )
    .orderBy(posts.id)
    .limit(limit);
}
Enter fullscreen mode Exit fullscreen mode

Transactions

async function createPostWithNotification(
  userId: number,
  postData: { title: string; content: string }
) {
  return db.transaction(async (tx) => {
    const [newPost] = await tx
      .insert(posts)
      .values({ ...postData, authorId: userId, published: true })
      .returning();

    await tx
      .update(users)
      .set({ updatedAt: new Date() })
      .where(eq(users.id, userId));

    return newPost;
  });
}
Enter fullscreen mode Exit fullscreen mode

Batch Queries

// Execute multiple queries in a single round trip
const [allUsers, publishedPosts, totalComments] = await db.batch([
  db.select().from(users).where(eq(users.isActive, true)),
  db.select().from(posts).where(eq(posts.published, true)),
  db.select({ count: count() }).from(comments),
]);
Enter fullscreen mode Exit fullscreen mode

Step 8: Wire Up the Express Server

Create src/index.ts:

import express from 'express';
import usersRouter from './routes/users.js';

const app = express();
app.use(express.json());

app.use('/users', usersRouter);

app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`API running at http://localhost:${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Step 9: Using Drizzle Studio for Debugging

One of Drizzle's killer features is the built-in database GUI:

DATABASE_URL=postgresql://user:pass@localhost:5432/mydb npm run db:studio
Enter fullscreen mode Exit fullscreen mode

This opens a browser-based interface at https://local.drizzle.studio where you can browse tables, run queries, and inspect your data without leaving your terminal workflow.

Performance Tips for Production

1. Use connection pooling appropriately:

const queryClient = postgres(connectionString, {
  max: 20,           // max connections
  idle_timeout: 30,  // seconds
  connect_timeout: 10,
});
Enter fullscreen mode Exit fullscreen mode

2. Avoid N+1 queries — use with for relational data:

// ❌ N+1 problem
const allUsers = await db.select().from(users);
for (const user of allUsers) {
  user.posts = await db.select().from(posts).where(eq(posts.authorId, user.id));
}

// ✅ Single query with Drizzle relations
const allUsers = await db.query.users.findMany({
  with: { posts: true },
});
Enter fullscreen mode Exit fullscreen mode

3. Select only what you need:

// ❌ Fetches all columns
const result = await db.select().from(users);

// ✅ Only the columns you actually use
const result = await db.select({
  id: users.id,
  name: users.name,
}).from(users);
Enter fullscreen mode Exit fullscreen mode

Connecting to External APIs

Once your Drizzle-powered API is live, you can extend it with external data sources. 1xAPI provides ready-to-use APIs for sports data, live scores, and streaming metadata that integrate cleanly with a Drizzle-backed Node.js API. The type-safe approach means you can map external API responses directly to your Drizzle schema with confidence.

Summary

In this guide you've built:

  • ✅ A typed PostgreSQL schema with indexes and foreign keys
  • ✅ Drizzle relations for efficient relational queries
  • ✅ Auto-generated SQL migrations with drizzle-kit
  • ✅ Full CRUD routes with returning() for immediate feedback
  • ✅ Transactions, batch queries, and cursor pagination
  • ✅ Production connection pooling tips

Drizzle ORM (v0.45.1 stable, v1.0.0 beta as of March 2026) hits the sweet spot between raw SQL control and full type safety. For any Node.js API built in 2026, it's hard to justify the overhead of Prisma or the looseness of Sequelize when Drizzle gives you this much for ~7.4kb.

Top comments (0)