Drizzle

Integrate Drizzle ORM with Strapi to add type-safe database operations alongside your headless CMS. Connect Drizzle's lightweight TypeScript-first toolkit to handle complex queries, custom tables, and edge deployments while Strapi manages content types and admin functionality

Drizzle logo

What Is Drizzle ORM?

Drizzle ORM is a lightweight, TypeScript-first database toolkit designed with a SQL-first philosophy. With a 7.4KB minified+gzipped footprint and zero runtime dependencies, it functions as a thin type-safe layer over database drivers rather than a heavy abstraction framework.

Schema definitions live directly in TypeScript files, eliminating code generation steps and providing SQL-like query syntax with full type inference. Drizzle supports PostgreSQL, MySQL, SQLite, and their serverless variants, including Neon, PlanetScale, and Cloudflare D1.

The minimal footprint and fast cold start characteristics make it particularly effective in edge computing scenarios where traditional ORMs introduce unacceptable overhead.

Why Integrate Drizzle ORM with Strapi?

Integrating Drizzle ORM with Strapi brings several key advantages for developers building type-safe, performant applications.

  • Type Safety: Drizzle's compile-time type inference automatically generates TypeScript types for all database operations. Your IDE catches type errors during development rather than at runtime, eliminating manual type definitions.
  • Query Flexibility: Complex joins, aggregations, and subqueries that are difficult through Strapi's Entity Service API become straightforward with Drizzle's SQL-like builder syntax. Multi-table operations with complex conditions provide the control necessary for sophisticated database operations while maintaining full TypeScript type safety.
  • Migration Management: Drizzle Kit auto-generates migration files by comparing schema changes against the database state, reducing boilerplate and human error. While Strapi's migration system is experimental and primarily designed for internal framework use, Drizzle provides production-ready migration tooling for custom tables.
  • Performance Benefits: The lightweight architecture delivers measurable results in serverless and edge deployments. Smaller bundle sizes mean faster cold starts, reduced memory footprint, and better performance for applications deployed to edge computing environments such as serverless platforms.

How to Integrate Drizzle ORM with Strapi

Drizzle ORM integrates as a complementary database layer for custom queries and type-safe operations—it cannot replace Strapi's core ORM.

This integration requires installing Drizzle alongside Strapi's existing Knex.js-based system, configuring database connections, defining custom schemas, and implementing a repository pattern within Strapi's controller-service architecture.

Understanding Architectural Boundaries

Before implementing, recognize the fundamental constraints. According to Strapi's database configuration documentation, Strapi can connect to external or existing databases, including those not created by Strapi itself, provided the schema matches the Strapi project schema, and the official documentation does not explicitly address or endorse replacing its core ORM.

Here's the thing: you're running two ORMs side by side. That sounds messy—and it can be if you're not clear about boundaries. But when you treat Strapi as your content management layer and Drizzle as your custom query layer, each system stays in its lane. Strapi's default system handles content types and admin panel operations, while Drizzle manages custom business logic and complex queries.

Database compatibility is limited to PostgreSQL, MySQL/MariaDB, and SQLite—the same databases Strapi 5 supports.

Step 1: Installing Dependencies

For PostgreSQL implementations:

npm i drizzle-orm pg dotenv
npm i -D drizzle-kit tsx @types/pg

For MySQL/MariaDB implementations:

npm i drizzle-orm mysql2 dotenv
npm i -D drizzle-kit tsx

These give you Drizzle's core library, the database driver, and development tools for migrations and TypeScript execution.

Step 2: Setting Up Your Project Structure

Organize your database files alongside Strapi's existing structure:

<project root>
├── drizzle/                  # Generated migration files
├── src/
│   ├── api/                  # Strapi's default structure
│   └── db/
│       ├── schema.ts         # Drizzle schema definitions
│       └── index.ts          # Database connection configuration
├── .env
├── drizzle.config.ts
└── package.json

This structure maintains clear boundaries between Drizzle's components and Strapi's native architecture.

Step 3: Creating the Configuration File

The configuration file lives in your project root as drizzle.config.ts:

import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: "postgresql",        // or "mysql" for MySQL/MariaDB
  schema: "./src/db/schema.ts",
  out: "./drizzle",
});

The dialect property specifies your database type, schema points to schema definitions, and out designates the migration output directory.

Step 4: Implementing the Database Connection

For PostgreSQL, set up src/db/index.ts with the following connection logic:

import 'dotenv/config';
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

export const db = drizzle(pool);

For MySQL/MariaDB implementations:

import 'dotenv/config';
import { createPool } from 'mysql2';
import { drizzle } from 'drizzle-orm/mysql2';

const pool = createPool(process.env.DATABASE_URL!);

export const db = drizzle(pool);

Both PostgreSQL and MySQL implementations use connection pooling to manage database connections efficiently. According to Drizzle's documentation, connection pooling settings must be carefully configured and coordinated between Strapi's built-in ORM and Drizzle to avoid connection exhaustion when running dual ORM systems.

One thing that'll come back to haunt you if you're not careful: connection pooling. Both Strapi and Drizzle manage separate pools from the same connection limit—configure those pool.min and pool.max settings thoughtfully or you'll hit database connection limits in production.

Step 5: Defining Type-Safe Schemas

Define your tables with type-safe schemas in src/db/schema.ts:

import { pgTable, integer, varchar, timestamp } from 'drizzle-orm/pg-core';

export const usersTable = pgTable('users', {
  id: integer().primaryKey().generatedAlwaysAsIdentity(),
  name: varchar({ length: 255 }).notNull(),
  email: varchar({ length: 255 }).notNull().unique(),
  createdAt: timestamp().defaultNow().notNull(),
});

For MySQL implementations, import from drizzle-orm/mysql-core and use mysqlTable with int instead of integer.

Step 6: Implementing the Repository Pattern

Set up a repository pattern by creating src/api/[content-type]/repositories/user.repository.ts:

import { eq } from 'drizzle-orm';
import { db } from '../../../db';
import { usersTable } from '../../../db/schema';

export class UserRepository {
  async findById(userId: number) {
    const [user] = await db
      .select()
      .from(usersTable)
      .where(eq(usersTable.id, userId));
    return user;
  }

  async findAll() {
    return await db.select().from(usersTable);
  }

  async create(data: { name: string; email: string }) {
    const [user] = await db
      .insert(usersTable)
      .values(data)
      .returning();
    return user;
  }

  async update(userId: number, data: Partial<{ name: string; email: string }>) {
    const [user] = await db
      .update(usersTable)
      .set(data)
      .where(eq(usersTable.id, userId))
      .returning();
    return user;
  }

  async delete(userId: number) {
    await db
      .delete(usersTable)
      .where(eq(usersTable.id, userId));
  }
}

This repository encapsulates all Drizzle database operations, providing clean abstraction that separates database logic from business logic.

Step 7: Building the Custom Service Layer

Your service layer sits between controllers and repositories. Implement it in src/api/[content-type]/services/user.service.ts:

import { UserRepository } from '../repositories/user.repository';

export default {
  async getUser(userId: number) {
    const repository = new UserRepository();
    return await repository.findById(userId);
  },

  async getAllUsers() {
    const repository = new UserRepository();
    return await repository.findAll();
  },

  async createUser(data: { name: string; email: string }) {
    const repository = new UserRepository();
    return await repository.create(data);
  },

  async updateUser(userId: number, data: Partial<{ name: string; email: string }>) {
    const repository = new UserRepository();
    return await repository.update(userId, data);
  },

  async deleteUser(userId: number) {
    const repository = new UserRepository();
    await repository.delete(userId);
  },
};

This service layer provides the interface that controllers consume, maintaining Strapi's architectural conventions while leveraging Drizzle's type safety.

Step 8: Creating Custom Controllers

Controllers handle HTTP requests and call your services. The implementation in src/api/[content-type]/controllers/user.controller.ts looks like this:

export default {
  async find(ctx) {
    try {
      const users = await strapi.service('api::user.user').getAllUsers();
      ctx.body = users;
    } catch (err) {
      ctx.throw(500, err);
    }
  },

  async findOne(ctx) {
    const { id } = ctx.params;
    try {
      const user = await strapi.service('api::user.user').getUser(Number(id));
      ctx.body = user;
    } catch (err) {
      ctx.throw(500, err);
    }
  },

  async create(ctx) {
    const { name, email } = ctx.request.body;
    try {
      const user = await strapi.service('api::user.user').createUser({ name, email });
      ctx.body = user;
    } catch (err) {
      ctx.throw(500, err);
    }
  },

  async update(ctx) {
    const { id } = ctx.params;
    const { name, email } = ctx.request.body;
    try {
      const user = await strapi.service('api::user.user').updateUser(Number(id), { name, email });
      ctx.body = user;
    } catch (err) {
      ctx.throw(500, err);
    }
  },

  async delete(ctx) {
    const { id } = ctx.params;
    try {
      await strapi.service('api::user.user').deleteUser(Number(id));
      ctx.body = { message: 'User deleted successfully' };
    } catch (err) {
      ctx.throw(500, err);
    }
  },
};

These controllers follow Strapi's Koa.js-based request handling pattern, integrating seamlessly with Strapi's routing system while executing Drizzle queries through the service and repository layers.

Step 9: Managing Migrations

Generate migration files based on schema changes:

npx drizzle-kit generate

Apply migrations to the database:

npx drizzle-kit push

The generate command creates SQL migration files by comparing the current Drizzle schema snapshot with the most recent snapshot from previous migrations, not by comparing against the current database state. The push command pushes schema changes directly to the configured database without generating migration files.

Step 10: Configuring Environment Variables

Add to .env:

# For PostgreSQL
DATABASE_URL=postgresql://username:password@localhost:5432/database_name

# For MySQL
DATABASE_URL=mysql://username:password@localhost:3306/database_name

These connection strings should point to the same database Strapi uses, or to a separate database if implementing a multi-database architecture.

Production Considerations

The integration operates within specific boundaries defined by Strapi's architecture. Strapi does not officially support replacing its ORM—external ORMs like Drizzle can be integrated by managing connections and queries outside the default ORM, but this requires custom development effort and is not officially supported.

Connection pooling settings must be compatible with your deployment environment to avoid connection exhaustion when running dual ORM systems. Migration coordination requires managing migrations separately between Strapi's native system and Drizzle's migration toolkit to avoid conflicts. Maintain Strapi's default ORM for content type management to preserve admin panel functionality, permissions, and Strapi-specific features.

Project Example: Custom User Profiles with Type Safety

This example demonstrates integrating Drizzle ORM alongside Strapi for custom user profile data—Strapi manages content types through its admin panel, while Drizzle handles a custom user profiles table with additional fields not managed by Strapi's default user system.

Schema Definition

Using the pattern from Step 5, define a custom profiles table in src/db/schema.ts:

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

export const userProfiles = pgTable('user_profiles', {
  id: serial('id').primaryKey(),
  userId: varchar('user_id', { length: 255 }).notNull().unique(),
  bio: text('bio'),
  website: varchar('website', { length: 500 }),
  location: varchar('location', { length: 255 }),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

Repository Implementation

Following Step 6's repository pattern in src/api/profiles/repositories/profile.repository.ts:

import { eq } from 'drizzle-orm';
import { db } from '../../../db';
import { userProfiles } from '../../../db/schema';

export class ProfileRepository {
  async findByUserId(userId: string) {
    const [profile] = await db
      .select()
      .from(userProfiles)
      .where(eq(userProfiles.userId, userId));
    return profile;
  }

  async create(data: { userId: string; bio?: string; website?: string; location?: string }) {
    const [profile] = await db
      .insert(userProfiles)
      .values(data)
      .returning();
    return profile;
  }

  async update(userId: string, data: Partial<{ bio: string; website: string; location: string }>) {
    const [profile] = await db
      .update(userProfiles)
      .set({ ...data, updatedAt: new Date() })
      .where(eq(userProfiles.userId, userId))
      .returning();
    return profile;
  }
}

Service Layer

Implementing Step 7's service pattern in src/api/profiles/services/profile.service.ts:

import { ProfileRepository } from '../repositories/profile.repository';

export default {
  async getProfile(userId: string) {
    const repository = new ProfileRepository();
    return await repository.findByUserId(userId);
  },

  async createProfile(data: { userId: string; bio?: string; website?: string; location?: string }) {
    const repository = new ProfileRepository();
    return await repository.create(data);
  },

  async updateProfile(userId: string, data: Partial<{ bio: string; website: string; location: string }>) {
    const repository = new ProfileRepository();
    return await repository.update(userId, data);
  },
};

Controller Implementation

Following Step 8's controller pattern in src/api/profiles/controllers/profile.controller.ts:

export default {
  async find(ctx) {
    const userId = ctx.state.user?.id;
    if (!userId) {
      return ctx.throw(401, 'Unauthorized');
    }
    
    try {
      const profile = await strapi.service('api::profiles.profile').getProfile(userId);
      ctx.body = profile;
    } catch (err) {
      ctx.throw(500, err);
    }
  },

  async create(ctx) {
    const userId = ctx.state.user?.id;
    if (!userId) {
      return ctx.throw(401, 'Unauthorized');
    }
    
    const { bio, website, location } = ctx.request.body;
    try {
      const profile = await strapi.service('api::profiles.profile').createProfile({
        userId,
        bio,
        website,
        location,
      });
      ctx.body = profile;
    } catch (err) {
      ctx.throw(500, err);
    }
  },

  async update(ctx) {
    const userId = ctx.state.user?.id;
    if (!userId) {
      return ctx.throw(401, 'Unauthorized');
    }
    
    const { bio, website, location } = ctx.request.body;
    try {
      const profile = await strapi.service('api::profiles.profile').updateProfile(userId, {
        bio,
        website,
        location,
      });
      ctx.body = profile;
    } catch (err) {
      ctx.throw(500, err);
    }
  },
};

Why This Pattern Works

Strapi manages the core user authentication and content management through its admin panel—Draft & Publish works as expected, permissions control content access, and the Media Library handles images.

Drizzle manages custom database operations for the user profiles table with full TypeScript type safety. The type-safe queries prevent runtime errors, TypeScript inference provides autocomplete in your IDE, and the SQL-like syntax gives you direct control over query execution.

This separation means you're not fighting either system—you're using each for what it does best.

Strapi Open Office Hours

If you have any questions about Strapi 5 or just would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours, Monday through Friday, from 12:30 pm to 1:30 pm CST: Strapi Discord Open Office Hours.

For more details, visit the Strapi documentation and Drizzle ORM documentation.

Frequently Answered Questions

No, Drizzle ORM cannot replace Strapi's core ORM. Strapi's content types, admin panel, and permissions system remain tied to its built-in database layer. The recommended pattern uses Drizzle as a complementary tool for custom queries while maintaining Strapi's default ORM for content management, preserving all Strapi features while adding type safety.

Drizzle ORM integration supports PostgreSQL (14+), MySQL (8.0+), MariaDB (10.3+), and SQLite (3+)—the same databases Strapi supports. Serverless variants including Neon, Vercel Postgres, PlanetScale, and Cloudflare D1 work through SQL-compatible protocols. Both systems can share one database or use separate databases. MongoDB and NoSQL databases are not supported.

Manage migrations separately. Strapi's migration system handles content type schemas, while Drizzle Kit manages custom tables. Generate migrations with npx drizzle-kit generate, then apply with npx drizzle-kit migrate (production) or npx drizzle-kit push (development). Run Strapi migrations first, then Drizzle migrations, storing all files in version control.

No, Drizzle operates alongside Strapi without affecting admin functionality. The admin panel continues managing content types, Draft & Publish works normally, and permissions remain intact. Drizzle-managed tables won't appear in the admin panel unless you build custom interfaces—content editors work unaffected while developers access type-safe queries through custom services.

Running dual ORMs requires careful connection pool configuration since both Strapi and Drizzle manage separate pools consuming the same connection limit. Monitor pool.min, pool.max, and timeout parameters carefully. Drizzle's 7.4KB bundle size benefits serverless deployments with faster cold starts. Performance gains materialize in edge computing, complex analytical queries, and applications requiring SQL optimization beyond Strapi's Entity Service API.