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 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.
Integrating Drizzle ORM with Strapi brings several key advantages for developers building type-safe, performant applications.
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.
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.
For PostgreSQL implementations:
npm i drizzle-orm pg dotenv
npm i -D drizzle-kit tsx @types/pgFor MySQL/MariaDB implementations:
npm i drizzle-orm mysql2 dotenv
npm i -D drizzle-kit tsxThese give you Drizzle's core library, the database driver, and development tools for migrations and TypeScript execution.
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.jsonThis structure maintains clear boundaries between Drizzle's components and Strapi's native architecture.
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.
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.
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.
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.
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.
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.
Generate migration files based on schema changes:
npx drizzle-kit generateApply migrations to the database:
npx drizzle-kit pushThe 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.
Add to .env:
# For PostgreSQL
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
# For MySQL
DATABASE_URL=mysql://username:password@localhost:3306/database_nameThese connection strings should point to the same database Strapi uses, or to a separate database if implementing a multi-database architecture.
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.
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.
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(),
});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;
}
}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);
},
};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);
}
},
};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.
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.
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.