Claude Code Boilerplate
FeaturesPricingDocsBlog
Get started →

Product

  • Features
  • Pricing
  • Skills
  • Roadmap

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Refund Policy
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

Drizzle ORM with Neon DB -- migrations, relations, and query patterns

May 18, 2026
drizzle-ormneon-dbnextjs

Drizzle ORM with Neon DB -- migrations, relations, and query patterns

Drizzle gives you SQL-level control with TypeScript types. Neon gives you serverless Postgres that scales to zero. Together they are a solid production stack -- but there are a handful of patterns worth knowing before you hit your first migration conflict or relations query that silently returns nothing.

This post covers the full picture: schema definition, relations, migrations, and the query patterns that keep your repo layer clean.

Setup

Install the packages:

npm install drizzle-orm @neondatabase/serverless
npm install -D drizzle-kit

Create drizzle.config.ts at the repo root:

import { defineConfig } from 'drizzle-kit';
 
export default defineConfig({
  schema: './db/schema.ts',
  out: './db/migrations',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

Create the db client in db/drizzle.ts:

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(sql, { schema });

The { schema } option is required if you want to use db.query.* with relations. Without it, Drizzle does not know which tables exist.

db/schema.ts re-exports every table so Drizzle config and the db client have a single import:

export * from '../modules/user/user.schema';
export * from '../modules/post/post.schema';
export * from '../modules/comment/comment.schema';

Schema definition

Tables live in modules/<name>/<name>.schema.ts:

import { pgTable, uuid, text, boolean, timestamp } from 'drizzle-orm/pg-core';
 
export const postTable = pgTable('posts', {
  id: uuid('id').defaultRandom().primaryKey(),
  slug: text('slug').notNull().unique(),
  title: text('title').notNull(),
  content: text('content').notNull(),
  published: boolean('published').notNull().default(false),
  authorId: uuid('author_id').notNull().references(() => userTable.id, { onDelete: 'cascade' }),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

Naming rules that matter:

  • Table names are plural: posts, users, comments
  • Column names are snake_case in the DB: author_id, created_at
  • TypeScript export names are camelCase + Table suffix: postTable, userTable
  • Never name exports PostSchema -- that collides with Zod schema naming conventions

Relations

Relations live in a separate file: modules/<name>/<name>.relations.ts.

import { relations } from 'drizzle-orm';
import { postTable } from './post.schema';
import { userTable } from '../user/user.schema';
import { commentTable } from '../comment/comment.schema';
 
export const postRelations = relations(postTable, ({ one, many }) => ({
  author: one(userTable, {
    fields: [postTable.authorId],
    references: [userTable.id],
  }),
  comments: many(commentTable),
}));

Register both the schema and the relations in db/drizzle.ts:

import * as schema from './schema';
import * as postRelations from '../modules/post/post.relations';
import * as commentRelations from '../modules/comment/comment.relations';
 
export const db = drizzle(sql, {
  schema: { ...schema, ...postRelations, ...commentRelations },
});

If you forget to register a relations file, db.query.postTable.findMany({ with: { author: true } }) returns the post rows but author is undefined on every row -- no error, just missing data.

Migrations

Two commands, two use cases:

npm run db:generate   # generates a SQL migration file from schema changes
npm run db:migrate    # applies pending migrations to the database

db:generate diffs your current schema against the last migration and writes a new .sql file to db/migrations/. Always review the generated SQL before running db:migrate -- Drizzle will drop columns it cannot reconcile.

db:push syncs the schema directly without generating a migration file. Use it in local dev when iterating fast. Never use it against a production database.

After adding a new module:

npm run db:generate
npm run db:migrate

After renaming a column, Drizzle will generate a DROP COLUMN + ADD COLUMN. If the column has data, write the migration by hand instead.

Query patterns

All queries live in modules/<name>/<name>.repo.ts. No business logic, no HTTP concepts -- just typed DB access.

Select all:

import { db } from '@/db/drizzle';
import { postTable } from './post.schema';
import { eq, desc } from 'drizzle-orm';
 
export async function findAllPosts() {
  return db.select().from(postTable).orderBy(desc(postTable.createdAt));
}

Select by field:

export async function findPostBySlug(slug: string) {
  const rows = await db
    .select()
    .from(postTable)
    .where(eq(postTable.slug, slug))
    .limit(1);
  return rows[0] ?? null;
}

With relations (using query API):

export async function findPostWithAuthor(id: string) {
  return db.query.postTable.findFirst({
    where: eq(postTable.id, id),
    with: { author: true },
  });
}

This only works if postRelations is registered in the db client's schema option.

Insert:

import { type NewPost } from './post.types';
 
export async function insertPost(data: NewPost) {
  const rows = await db.insert(postTable).values(data).returning();
  return rows[0];
}

NewPost is inferred from the schema:

// post.types.ts
import { type InferSelectModel, type InferInsertModel } from 'drizzle-orm';
import { postTable } from './post.schema';
 
export type Post = InferSelectModel<typeof postTable>;
export type NewPost = InferInsertModel<typeof postTable>;

Update:

export async function updatePost(id: string, data: Partial<NewPost>) {
  const rows = await db
    .update(postTable)
    .set({ ...data, updatedAt: new Date() })
    .where(eq(postTable.id, id))
    .returning();
  return rows[0] ?? null;
}

Delete:

export async function deletePost(id: string) {
  await db.delete(postTable).where(eq(postTable.id, id));
}

Common gotchas

Relations silently missing: If db.query.* returns rows without the with fields populated, the relations file is not registered in the db client. Add it to the schema spread in db/drizzle.ts.

returning() is Postgres-only: Drizzle supports returning() on insert, update, and delete for Postgres. Do not expect it on SQLite or MySQL.

updatedAt does not auto-update: Drizzle does not manage updatedAt automatically. Set it explicitly in every update() call: .set({ ...data, updatedAt: new Date() }).

Neon cold starts on first query: Neon serverless connections have a cold start on the first query after inactivity. This is normal -- subsequent queries in the same request are fast. Use connection pooling (the -pooler URL variant) in production to reduce cold start frequency.

Schema drift in preview environments: Each Vercel preview branch points at the same Neon database by default. Run db:migrate after deploying a branch with schema changes, or use Neon branching to give each preview its own DB branch.

Takeaway

The repo layer is thin by design: typed queries, no business logic, no HTTP imports. Keep it that way and migrations become the only complexity worth managing. When you do hit a migration conflict -- usually a rename or a NOT NULL column with existing rows -- write the SQL by hand rather than trusting the generated diff.