A server-rendered admin panel for Drizzle ORM applications. Provides automatic CRUD interfaces for your database tables with minimal configuration.
- Zero frontend build step - server-rendered HTML with Tailwind CSS via CDN
- Dark mode UI inspired by shadcn
- JWT authentication with bcrypt password hashing
- File-based resource registration
- Custom member and collection actions
- PostgreSQL support (more dialects planned)
pnpm add drizzle-admin
# or
npm install drizzle-adminDrizzleAdmin expects you already have drizzle-orm and a database driver (e.g., pg) in your project.
DrizzleAdmin requires an admin users table with specific columns. Add this to your Drizzle schema:
// db/schema/admin-users.ts
import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'
export const adminUsers = pgTable('admin_users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})The table must have these columns (TypeScript property names): id, email, passwordHash, createdAt, updatedAt.
Create a directory for your admin resources. Each file exports a resource definition using defineResource():
admin/
resources/
posts.ts
users.ts
categories.ts
// admin/resources/posts.ts
import { defineResource } from 'drizzle-admin'
import { posts } from '../../db/schema/posts'
export default defineResource(posts)// admin/index.ts
import { DrizzleAdmin, defineConfig } from 'drizzle-admin'
import { db } from '../db'
import { adminUsers } from '../db/schema/admin-users'
const admin = new DrizzleAdmin(
defineConfig({
db,
dialect: 'postgresql',
adminUsers,
sessionSecret: process.env.ADMIN_SESSION_SECRET!,
resourcesDir: './admin/resources',
port: 3001,
})
)
// Seed the first admin user
await admin.seed({
email: 'admin@example.com',
password: 'changeme',
})
// Start the admin server
await admin.start()Run it:
npx tsx admin/index.tsThen open http://localhost:3001 and sign in.
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
db |
Drizzle DB instance | Yes | - | Your Drizzle database connection |
dialect |
'postgresql' |
Yes | - | Database dialect (only PostgreSQL supported currently) |
adminUsers |
Drizzle table | Yes | - | Table for admin user authentication |
sessionSecret |
string |
Yes | - | Secret key for signing JWT tokens (use a strong random string) |
resourcesDir |
string |
Yes | - | Path to directory containing resource definition files |
port |
number |
No | 3001 |
Port to run the admin server on |
The simplest resource just wraps a Drizzle table:
import { defineResource } from 'drizzle-admin'
import { posts } from '../../db/schema/posts'
export default defineResource(posts)DrizzleAdmin will automatically:
- Derive the URL path from the table name (
posts->/posts) - Derive the display name (
posts->Post) - Extract all columns and render appropriate form inputs
- Hide password columns from views
- Skip auto-managed columns (primary keys,
createdAt,updatedAt) in forms
Pass a second argument to defineResource() to customize behavior:
import { defineResource } from 'drizzle-admin'
import { posts } from '../../db/schema/posts'
export default defineResource(posts, {
index: {
perPage: 50,
exclude: ['body'], // hide 'body' column from the listing
},
show: {
exclude: ['internalNotes'],
},
form: {
columns: ['title', 'body', 'status'], // only show these fields in forms
},
})| Option | Type | Description |
|---|---|---|
perPage |
number |
Records per page (default: 20) |
columns |
string[] |
Whitelist - only show these columns |
exclude |
string[] |
Blacklist - hide these columns |
| Option | Type | Description |
|---|---|---|
columns |
string[] |
Whitelist - only show these columns |
exclude |
string[] |
Blacklist - hide these columns |
| Option | Type | Description |
|---|---|---|
columns |
string[] |
Whitelist - only show these fields |
exclude |
string[] |
Blacklist - hide these fields |
export default defineResource(posts, {
memberActions: [
{
name: 'Archive',
destructive: true, // shows confirmation modal (default: true)
handler: async (id, db) => {
await (db as any)
.update(posts)
.set({ status: 'archived' })
.where(eq(posts.id, Number(id)))
},
},
{
name: 'Publish',
destructive: false, // submits directly without confirmation
handler: async (id, db) => {
await (db as any)
.update(posts)
.set({ status: 'published', publishedAt: new Date() })
.where(eq(posts.id, Number(id)))
},
},
],
})| Option | Type | Description |
|---|---|---|
name |
string |
Button label for the action |
handler |
(id, db) => Promise<void> |
Function that receives the record ID and db instance |
destructive |
boolean |
If true (default), shows a confirmation modal before executing |
Member actions appear on the show page for each record.
export default defineResource(posts, {
collectionActions: [
{
name: 'Publish All Drafts',
handler: async (c, db) => {
await (db as any)
.update(posts)
.set({ status: 'published' })
.where(eq(posts.status, 'draft'))
},
},
],
})| Option | Type | Description |
|---|---|---|
name |
string |
Button label for the action |
handler |
(c, db) => Promise<void | Response> |
Function that receives Hono context and db. Can return a Response for downloads. |
Collection actions appear on the index page alongside the "Create New" button.
DrizzleAdmin ships with a CSV export collection action:
import { defineResource } from 'drizzle-admin'
import { createCsvExportAction } from 'drizzle-admin/actions/csv'
import { posts } from '../../db/schema/posts'
export default defineResource(posts, {
collectionActions: [
createCsvExportAction(posts),
],
})This adds an "Export CSV" button to the index page that downloads all records as a CSV file.
DrizzleAdmin automatically maps Drizzle column types to appropriate form inputs:
| Drizzle Type | Admin Input | Notes |
|---|---|---|
text, varchar |
Text input | |
integer, serial |
Number input | |
boolean |
Checkbox | |
timestamp, date |
Datetime picker | |
json, jsonb |
Textarea | Displays formatted JSON |
pgEnum |
Select dropdown | Options derived from enum values |
| Password columns | Password input | Detected by column name containing "password" |
These columns are automatically excluded from create/edit forms:
- Primary key columns
createdAt/created_at(when they have a default value)updatedAt/updated_at(when they have a default value)
Password columns are automatically hidden from index and show views.
DrizzleAdmin uses JWT-based authentication stored in HTTP-only cookies:
- Passwords are hashed with bcrypt (12 salt rounds)
- Sessions expire after 24 hours
- CSRF protection on all form submissions
- Cookies are
HttpOnly,SameSite=Strict, andSecurein production
Use the seed() method to create admin users. It's safe to call on every startup - it skips if the email already exists:
await admin.seed({ email: 'admin@example.com', password: 'changeme' })For each resource, DrizzleAdmin generates these routes:
| Method | Path | Description |
|---|---|---|
| GET | /:resource |
Index - paginated table listing |
| GET | /:resource/new |
Create form |
| POST | /:resource |
Create record |
| GET | /:resource/:id |
Show record details |
| GET | /:resource/:id/edit |
Edit form |
| POST | /:resource/:id?_method=PUT |
Update record |
| POST | /:resource/:id?_method=DELETE |
Delete record |
| POST | /:resource/:id/actions/:name |
Execute member action |
| POST | /:resource/actions/:name |
Execute collection action |
Authentication routes:
| Method | Path | Description |
|---|---|---|
| GET | /login |
Login page |
| POST | /login |
Authenticate |
| GET/POST | /logout |
Sign out |
The root path (/) redirects to the first resource's index page.
DrizzleAdmin derives URL paths and display names from your SQL table names:
| SQL Table Name | URL Path | Display Name |
|---|---|---|
posts |
/posts |
Post |
sale_orders |
/sale-orders |
Sale Order |
user_profiles |
/user-profiles |
User Profile |
Here's a complete example with a blog schema:
// db/schema.ts
import { pgTable, serial, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core'
export const statusEnum = pgEnum('post_status', ['draft', 'published', 'archived'])
export const adminUsers = pgTable('admin_users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
body: text('body').notNull(),
status: statusEnum('status').default('draft').notNull(),
featured: boolean('featured').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
export const categories = pgTable('categories', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
description: text('description'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})// admin/resources/posts.ts
import { defineResource } from 'drizzle-admin'
import { createCsvExportAction } from 'drizzle-admin/actions/csv'
import { eq } from 'drizzle-orm'
import { posts } from '../../db/schema'
export default defineResource(posts, {
index: {
perPage: 25,
exclude: ['body'],
},
memberActions: [
{
name: 'Publish',
destructive: false,
handler: async (id, db) => {
await (db as any).update(posts).set({ status: 'published' }).where(eq(posts.id, Number(id)))
},
},
{
name: 'Archive',
handler: async (id, db) => {
await (db as any).update(posts).set({ status: 'archived' }).where(eq(posts.id, Number(id)))
},
},
],
collectionActions: [
createCsvExportAction(posts),
],
})// admin/resources/categories.ts
import { defineResource } from 'drizzle-admin'
import { categories } from '../../db/schema'
export default defineResource(categories)// admin/index.ts
import { DrizzleAdmin, defineConfig } from 'drizzle-admin'
import { drizzle } from 'drizzle-orm/node-postgres'
import { adminUsers } from '../db/schema'
const db = drizzle(process.env.DATABASE_URL!)
const admin = new DrizzleAdmin(
defineConfig({
db,
dialect: 'postgresql',
adminUsers,
sessionSecret: process.env.ADMIN_SESSION_SECRET!,
resourcesDir: './admin/resources',
})
)
await admin.seed({ email: 'admin@example.com', password: 'changeme' })
await admin.start()Creates a new admin instance. Validates the admin users table schema and dialect at construction time.
Creates an admin user if one with that email doesn't already exist. Safe to call on every startup.
Loads resources, sets up all routes, and starts the HTTP server. This is the main entry point.
Loads and validates resources without starting the server. Called automatically by start().
Returns the loaded resource definitions. Only available after initialize() or start() has been called.
Type-safe helper for creating configuration objects. Provides TypeScript inference for your admin users table type.
Creates a resource definition for DrizzleAdmin to load. Must be the default export of a file in your resourcesDir.
Factory function that creates a collection action for exporting all records as CSV. Import from drizzle-admin/actions/csv.
pnpm install
pnpm test # run tests
pnpm typecheck # type check without emitting
pnpm build # compile TypeScriptMIT