Add files to your Postgres branches via Neon Object Storage, our S3-compatible object store
/ORMs/Better Drizzle

Connect from Better Drizzle to Neon

Learn how to connect to Neon from Better Drizzle

What you will learn:

  • How to set up better-drizzle with Lakebase Postgres

  • How to define schemas, seed, and query your database

  • How to use plugins, hooks, and transactions in better-drizzle

better-drizzle is a thin wrapper around Drizzle ORM that gives every table a consistent, type-safe API without replacing Drizzle itself. If you are already using Drizzle with Lakebase Postgres, better-drizzle removes the repetitive query glue you would otherwise rewrite in every service while staying close to the metal.

better-drizzle is sponsored by Neon, and is maintained by Hiago Almeida.

What better-drizzle adds

better-drizzle wraps your existing Drizzle client and generates one delegate per table. You get:

  • Consistent CRUD: findMany, findFirst, findUnique, create, update, delete, upsert, and upsertMany on every table
  • Typed relation loading: include and select with full type inference from your Drizzle schema
  • Nested relation filters: some, every, none, and is for filtering by related rows
  • Unified pagination: Offset and cursor pagination returning the same { data, pagination } shape
  • Lifecycle hooks: Cross-cutting concerns like audit trails, tracing, and metrics without sprinkling them through every call
  • First-class plugins: Timestamps, soft delete, and custom behavior packaged as reusable plugins
  • Transactions with savepoints: Nested transactions, rollback, afterCommit callbacks, and automatic retries

All of this compiles down to Drizzle queries. You still define your schema in Drizzle, choose your driver, and drop to raw SQL whenever you need to.

To connect a TypeScript/Node.js project to Lakebase Postgres using better-drizzle, follow these steps:

  1. Create a TypeScript/Node.js project

    Create a new directory for your project and navigate into it:

    mkdir better-drizzle-demo
    cd better-drizzle-demo

    Initialize a new Node.js project with a package.json file:

    npm init -y
  2. Create a project on Neon

    If you do not have one already, create a project.

    1. Navigate to the Projects page in the console.
    2. Click New Project.
    3. Specify your project settings and click Create Project.
  3. Get your connection string

    Find your database connection string by clicking the Connect button on your Project Dashboard to open the Connect to your database modal. Select a branch, a user, and the database you want to connect to. A connection string is constructed for you. Connection details modal The connection string includes the user name, password, hostname, and database name.

    Create a .env file in your project's root directory and add the connection string to it. Your .env file should look like this:

    DATABASE_URL="postgresql://[user]:[password]@[neon_hostname]/[dbname]?sslmode=require&channel_binding=require"

    note

    Neon supports both direct and pooled connection strings, which you can find by clicking the Connect button on your Project Dashboard. A pooled connection string (the hostname includes -pooler) routes through a PgBouncer connection pool, which is ideal for your application at runtime. However, using a pooled connection string for migrations can lead to errors. Use a direct (non-pooled) connection when running Drizzle Kit migrations. For more information, see Connection pooling and Schema migration with Drizzle ORM.

  4. Install dependencies

    Install the dependencies:

    npm install better-drizzle pg drizzle-orm
    npm install -D drizzle-kit@0.21.4 @types/pg @types/node dotenv

    Drizzle Kit Compatibility

    better-drizzle is compatible with drizzle-kit@0.21.4 and Drizzle ORM versions up to ^0.30.0.

  5. Configure Drizzle Kit

    Drizzle Kit uses a configuration file to manage schema and migrations. Create a drizzle.config.ts file in your project root:

    drizzle.config.ts
    import 'dotenv/config';
    import { defineConfig } from 'drizzle-kit';
    
    if (!process.env.DATABASE_URL) {
      throw new Error('DATABASE_URL is not set in the .env file');
    }
    
    export default defineConfig({
      schema: './src/schema.ts',
      out: './drizzle',
      dialect: 'postgresql',
      dbCredentials: {
        url: process.env.DATABASE_URL,
      },
    });
  6. Define your schema and relations

    better-drizzle reads your Drizzle schema (including relations) to generate each table's typed API. The relations you define power include, select, and nested relation filters.

    Create a src/schema.ts file with two tables: users and posts. Each user can have many posts, and each post belongs to one user.

    src/schema.ts
    import { relations } from 'drizzle-orm';
    import { boolean, integer, pgTable, serial, text } from 'drizzle-orm/pg-core';
    
    export const users = pgTable('users', {
      id: serial('id').primaryKey(),
      email: text('email').notNull().unique(),
      name: text('name').notNull(),
      active: boolean('active').notNull().default(true),
    });
    
    export const posts = pgTable('posts', {
      id: serial('id').primaryKey(),
      authorId: integer('author_id')
        .notNull()
        .references(() => users.id),
      title: text('title').notNull(),
      published: boolean('published').notNull().default(false),
    });
    
    export const usersRelations = relations(users, ({ many }) => ({
      posts: many(posts),
    }));
    
    export const postsRelations = relations(posts, ({ one }) => ({
      author: one(users, {
        fields: [posts.authorId],
        references: [users.id],
      }),
    }));
    
    export const schema = {
      users,
      usersRelations,
      posts,
      postsRelations,
    };
  7. Initialize the Better Drizzle client

    Create a standard Drizzle client and wrap it with better() to get the Better client. Create a src/db.ts file:

    src/db.ts
    import { better } from 'better-drizzle';
    import { drizzle } from 'drizzle-orm/node-postgres';
    import { Pool } from 'pg';
    import { schema } from './schema';
    import 'dotenv/config';
    
    const pool = new Pool({ connectionString: process.env.DATABASE_URL! });
    const db = drizzle(pool, { schema });
    
    export const client = better(db, { schema });
  8. Generate migrations

    Generate your migration files using Drizzle Kit:

    npx drizzle-kit generate
  9. Apply migrations

    Apply the generated SQL migrations to your Neon database:

    npx drizzle-kit migrate
  10. Seed the database

    Create a src/seed.ts file to populate the database with some users and posts:

    src/seed.ts
    import { client } from './db';
    
    async function seed() {
      await client.users.upsertMany({
        data: [
          { email: 'alice@example.com', name: 'Alice', active: true },
          { email: 'bob@example.com', name: 'Bob', active: false },
          { email: 'charlie@example.com', name: 'Charlie', active: true },
        ],
        target: ['email'],
        update: ['name'],
      });
    
      const alice = await client.users.findUnique({
        where: { email: 'alice@example.com' },
      });
    
      const bob = await client.users.findUnique({
        where: { email: 'bob@example.com' },
      });
    
      await client.posts.createMany({
        data: [
          { authorId: alice!.id, title: 'Hello World', published: true },
          { authorId: alice!.id, title: 'better-drizzle is great', published: true },
          { authorId: alice!.id, title: 'Neon Postgres rocks', published: true },
          { authorId: bob!.id, title: 'Secret draft', published: false },
        ],
      });
    
      console.log('Seeded 3 users and 4 posts.');
    }
    
    seed();

    Run the seed script:

    npx tsx src/seed.ts

Query with better-drizzle

Reads with typed relations

Each table gets a delegate (client.users, client.posts) with methods that are fully typed against your schema.

List active users with their three most recent published posts:

const users = await client.users.findMany({
  where: { active: true },
  include: {
    posts: {
      where: { published: true },
      select: { id: true, title: true },
      orderBy: [{ id: 'desc' }],
      take: 3,
    },
  },
  orderBy: [{ id: 'desc' }],
  take: 20,
});

The query returns active users with their published posts nested under each user:

[
  {
    "id": 3,
    "email": "charlie@example.com",
    "name": "Charlie",
    "active": true,
    "posts": []
  },
  {
    "id": 1,
    "email": "alice@example.com",
    "name": "Alice",
    "active": true,
    "posts": [
      { "id": 3, "title": "Neon Postgres rocks" },
      { "id": 2, "title": "better-drizzle is great" },
      { "id": 1, "title": "Hello World" }
    ]
  }
]

select vs include

By default, queries return all table columns.

  • Use include to return all columns plus nested relations (e.g., include: { posts: true }).
  • Use select to return only the specified columns and drop everything else (e.g., select: { id: true, title: true }).

select and include are mutually exclusive at the same query level, but you can nest a select inside an include to narrow the fields of a related table (as shown in the active users query above).

Find a single user by a unique field:

const alice = await client.users.findUnique({
  where: { email: 'alice@example.com' },
});

Filter posts by conditions on their related author with nested relation filters:

const posts = await client.posts.findMany({
  where: {
    published: true,
    author: { is: { active: true } },
  },
  select: {
    id: true,
    title: true,
    author: { select: { id: true, name: true } },
  },
  orderBy: [{ id: 'desc' }],
  take: 20,
});

The author: { is: { active: true } } filter keeps only posts where the author is active, excluding Bob's posts since he is inactive:

Writes with skipDuplicates and upsertMany

Create a new user, skipping if the email already exists

const maybeCreated = await client.users.create({
  data: {
    email: 'better@example.com',
    name: 'better',
    active: true,
  },
  skipDuplicates: ['email'],
});

if (!maybeCreated) {
  console.log('User already existed');
}

Bulk upsert multiple users in a single batch operation:

await client.users.upsertMany({
  data: [
    { email: 'alice@example.com', name: 'Alice Updated', active: true },
    { email: 'bob@example.com', name: 'Bob Updated', active: true },
  ],
  target: ['email'],
  update: ['name', 'active'],
  select: { id: true, name: true },
});

Pagination: one shape for offset and cursor

import { PaginationType } from 'better-drizzle';

// Offset pagination
const page1 = await client.users.paginate({
  type: PaginationType.Offset,
  where: { active: true },
  select: { id: true, name: true },
  orderBy: [{ id: 'desc' }],
  skip: 0,
  limit: 10,
});

Both offset and cursor pagination return the same { data, pagination } shape:

{
  "data": [
    { "id": 3, "name": "Charlie" },
    { "id": 1, "name": "Alice" }
  ],
  "pagination": { "count": 2, "hasNext": false, "hasPrevious": false }
}

Cursor pagination uses the last item's cursor to fetch the next page:

const page2 = await client.users.paginate({
  type: PaginationType.Cursor,
  where: { active: true },
  select: { id: true, name: true },
  after: { id: page1.data[page1.data.length - 1]?.id },
  limit: 10,
  orderBy: [{ id: 'desc' }],
});

Count and exists

const activeCount = await client.users.count({
  where: { active: true },
});

const exists = await client.users.exists({
  where: { id: 1 },
});

Use plugins for cross-cutting behavior

Plugins can manage timestamps, soft deletes, and custom behavior.

Timestamps

Auto-manage createdAt and updatedAt columns on every write:

npm install @better-drizzle/timestamps
src/db.ts
import { timestamps } from '@better-drizzle/timestamps';

const client = better(db, {
  schema,
  plugins: [
    timestamps({
      createdAt: 'createdAt',
      updatedAt: 'updatedAt',
    }),
  ],
});

Timestamps plugin

Use the column names defined in your Drizzle schema rather than the raw PostgreSQL column names. For example, if your schema specifies createdAt and updatedAt, those are the names you should reference in the plugin configuration - not the database’s created_at and updated_at.

The plugin automatically sets createdAt on row creation and updates updatedAt on every update. Ensure your schema has the createdAt and updatedAt columns defined as timestamps. For example:

src/schema.ts
import { relations } from "drizzle-orm";
import { boolean, integer, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: serial("id").primaryKey(),
  email: text("email").notNull().unique(),
  name: text("name").notNull(),
  active: boolean("active").notNull().default(true),
  createdAt: timestamp("created_at"),
  updatedAt: timestamp("updated_at"),
});

export const posts = pgTable("posts", {
  id: serial("id").primaryKey(),
  authorId: integer("author_id")
    .notNull()
    .references(() => users.id),
  title: text("title").notNull(),
  published: boolean("published").notNull().default(false),
  createdAt: timestamp("created_at"),
  updatedAt: timestamp("updated_at"),
});

Soft delete

To use soft deletes, add a deletedAt column to your schema:

src/schema.ts
export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  authorId: integer('author_id')
    .notNull()
    .references(() => users.id),
  title: text('title').notNull(),
  published: boolean('published').notNull().default(false),
  deletedAt: timestamp('deleted_at'),
});

Install and configure the @better-drizzle/soft-delete plugin:

npm install @better-drizzle/soft-delete
src/db.ts
import { softDelete } from '@better-drizzle/soft-delete';

const client = better(db, {
  schema,
  plugins: [
    softDelete({
      column: 'deletedAt',
      defaults: { mode: 'soft', visibility: 'without' },
    }),
  ],
});

// Soft-deletes the row instead of removing it
await client.posts.delete({ where: { id: 1 } });

// Get only deleted posts
await client.posts.findMany({ deleted: 'only' });

// Restore a soft-deleted row
await client.posts.restore({ where: { id: 1 } });

Add lifecycle hooks

Observe operations for auditing, metrics, or custom metadata:

db.ts
const client = better(db, {
  schema,
  hooks: {
    beforeCreate(ctx) {
      console.log('beforeCreate', ctx.action, ctx.table);
    },
    afterCreate(ctx) {
      console.log('afterCreate', ctx.row);
    },
    beforeQuery(ctx) {
      console.log('beforeQuery', ctx.action, ctx.args.where);
    },
    afterQuery(ctx) {
      console.log('afterQuery', ctx.action, ctx.result);
    },
  },
});

The example above shows create and query hooks, but Better Drizzle also supports beforeUpdate / afterUpdate, beforeDelete / afterDelete, transaction hooks (beforeTransaction, afterTransactionCommit, afterTransactionRollback, onTransactionError), raw SQL hooks (beforeRaw, afterRaw, onRawError), and a catch-all onError. See the hooks docs for details.

Hook callbacks receive ctx.meta, ideal for logging request IDs, tenant IDs, or user context in a server environment.

Transactions with savepoints

Transactions run on the client, with the callback receiving a Better client bound to the transaction context:

const user = await client.transaction(async (tx) => {
  const created = await tx.users.create({
    data: { email: 'new@example.com', name: 'New User', active: true },
  });

  await tx.posts.create({
    data: { authorId: created.id, title: 'Hello World', published: true },
  });

  tx.afterCommit(() => {
    console.log('Committed user', created.email);
  });

  return created;
});

Nested transactions use savepoints that roll back independently of the outer transaction:

await client.transaction(async (tx) => {
  await tx.users.create({
    data: { email: 'a@test.com', name: 'Alice', active: true },
  });

  try {
    await tx.transaction(async (nested) => {
      await nested.users.create({
        data: { email: 'b@test.com', name: 'Bob', active: true },
      });
      nested.rollback('duplicate email, rolling back nested savepoint');
    });
  } catch {
    // Only the nested savepoint was rolled back
  }

  await tx.users.create({
    data: { email: 'c@test.com', name: 'Charlie', active: true },
  });
});

Automatic retries

Opt into transaction retries for transient failures like deadlocks:

await client.transaction(
  async (tx) => {
    await tx.users.create({
      data: { email: 'retry@example.com', name: 'Retry', active: true },
    });
  },
  {
    retries: {
      attempts: 3,
      on: ['deadlock', 'serializationFailure'],
      delayMs: (attempt) => attempt * 25,
    },
  },
);

Summary

better-drizzle provides a type-safe, boilerplate-free API on top of Drizzle ORM, providing features like auto-pagination, soft deletes, lifecycle hooks, and nested transactions.

Other features

To explore more advanced capabilities of better-drizzle, refer to the following topics in the official documentation:

Resources

Need help?

Join our Discord Server to ask questions or see what others are doing with Neon. For paid plan support options, see Support.

Was this page helpful?
Edit on GitHub