Building a production-grade REST API in 2026 means choosing tools that are fast, type-safe, and maintainable. The combination of ElysiaJS 1.4 running on Bun with Drizzle ORM gives you exactly that — a stack that delivers over 290,000 requests per second in benchmarks while keeping your TypeScript types consistent from database schema all the way to your frontend client.
In this guide, you'll build a fully functional CRUD API with authentication, request validation, and automatic OpenAPI documentation — everything you need to take a project from zero to production in 2026.
Why ElysiaJS + Drizzle ORM in 2026?
Before we write any code, let's understand why this stack is worth your attention.
ElysiaJS 1.4 (released September 2025) introduced Standard Schema support, meaning you can now use Zod, Valibot, ArkType, or TypeBox for validation — your choice. More importantly, it benchmarks at 293,991 requests/second without validation and 223,924 req/s with validation (tested November 2025 on Intel i7-13700K with Bun 1.3.2). That's 2x faster than Encore's Rust-powered framework in the same benchmark.
Drizzle ORM hits its stride in 2026 as the go-to TypeScript ORM. Unlike Prisma, Drizzle compiles down to raw SQL with zero overhead. Unlike raw SQL, you get full TypeScript inference. The drizzle-typebox package creates a direct bridge to ElysiaJS — your database schema is your validation schema.
The result: when you change a column in Drizzle, TypeScript immediately flags every API handler that needs updating. No runtime surprises. No schema drift.
Prerequisites
-
Bun 1.3+ installed (
curl -fsSL https://bun.sh/install | bash) - PostgreSQL 16+ running locally or on Supabase/Neon
- Basic TypeScript knowledge
- Familiarity with REST API concepts
Project Setup
Create a new Bun project and install dependencies:
mkdir elysia-drizzle-api && cd elysia-drizzle-api
bun init -y
bun add elysia @elysiajs/swagger drizzle-orm drizzle-typebox postgres
bun add -d drizzle-kit @types/bun
Pin @sinclair/typebox to avoid version conflicts between drizzle-typebox and Elysia (a known gotcha in 2026):
# Check which version Elysia expects
grep "@sinclair/typebox" node_modules/elysia/package.json
Add the override in package.json:
{
"overrides": {
"@sinclair/typebox": "0.32.4"
}
}
Set up your environment variables in .env:
DATABASE_URL=postgres://user:password@localhost:5432/elysia_api
JWT_SECRET=your-super-secret-key-change-in-production
PORT=3000
Database Schema with Drizzle ORM
The key insight with this stack: define your schema once, use it everywhere. Create src/database/schema.ts:
import {
pgTable,
varchar,
text,
timestamp,
boolean,
integer,
serial
} from 'drizzle-orm/pg-core'
import { createId } from '@paralleldrive/cuid2'
// Users table
export const users = pgTable('users', {
id: varchar('id', { length: 128 })
.$defaultFn(() => createId())
.primaryKey(),
username: varchar('username', { length: 50 }).notNull().unique(),
email: varchar('email', { length: 255 }).notNull().unique(),
password: varchar('password', { length: 255 }).notNull(),
salt: varchar('salt', { length: 64 }).notNull(),
isActive: boolean('is_active').default(true).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
})
// Posts table
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: varchar('title', { length: 255 }).notNull(),
content: text('content').notNull(),
slug: varchar('slug', { length: 300 }).notNull().unique(),
authorId: varchar('author_id', { length: 128 })
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
published: boolean('published').default(false).notNull(),
viewCount: integer('view_count').default(0).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
})
export const table = {
users,
posts
} as const
export type Table = typeof table
Create drizzle.config.ts for migrations:
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/database/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!
}
})
Generate and run your first migration:
bunx drizzle-kit generate
bunx drizzle-kit migrate
Database Connection
Create src/database/index.ts:
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from './schema'
const queryClient = postgres(process.env.DATABASE_URL!, {
max: 20, // Connection pool size
idle_timeout: 30, // Close idle connections after 30s
connect_timeout: 10
})
export const db = drizzle(queryClient, { schema })
export type DB = typeof db
Converting Drizzle Schema to Elysia Validators
This is where the magic happens. Create src/validators/index.ts:
import { t } from 'elysia'
import { createInsertSchema, createSelectSchema } from 'drizzle-typebox'
import { table } from '../database/schema'
// ---- User validators ----
const _insertUser = createInsertSchema(table.users, {
email: t.String({ format: 'email' }),
username: t.String({ minLength: 3, maxLength: 50, pattern: '^[a-zA-Z0-9_]+$' }),
password: t.String({ minLength: 8, maxLength: 100 })
})
// Expose only fields safe for registration
export const CreateUserBody = t.Omit(_insertUser, ['id', 'salt', 'isActive', 'createdAt', 'updatedAt'])
// Login body — just email + password
export const LoginBody = t.Object({
email: t.String({ format: 'email' }),
password: t.String({ minLength: 8 })
})
// Safe user shape for responses (no password, no salt)
export const UserResponse = t.Omit(
createSelectSchema(table.users),
['password', 'salt']
)
// ---- Post validators ----
const _insertPost = createInsertSchema(table.posts, {
title: t.String({ minLength: 1, maxLength: 255 }),
content: t.String({ minLength: 10 }),
slug: t.String({ pattern: '^[a-z0-9-]+$' })
})
export const CreatePostBody = t.Omit(_insertPost, ['id', 'authorId', 'viewCount', 'createdAt', 'updatedAt'])
export const UpdatePostBody = t.Partial(
t.Omit(_insertPost, ['id', 'authorId', 'viewCount', 'createdAt', 'updatedAt'])
)
export const PostIdParam = t.Object({
id: t.Numeric({ minimum: 1 })
})
export const PaginationQuery = t.Object({
page: t.Optional(t.Numeric({ minimum: 1, default: 1 })),
limit: t.Optional(t.Numeric({ minimum: 1, maximum: 100, default: 20 }))
})
Because createInsertSchema reads your Drizzle table definition at compile time, if you add a NOT NULL column to your schema, TypeScript will immediately error on any validator that omits it. Schema drift becomes impossible.
Building the ElysiaJS Application
Create src/index.ts:
import { Elysia } from 'elysia'
import { swagger } from '@elysiajs/swagger'
import { authRoutes } from './routes/auth'
import { postRoutes } from './routes/posts'
import { db } from './database'
const app = new Elysia()
.use(
swagger({
documentation: {
info: {
title: 'ElysiaJS + Drizzle API',
version: '1.0.0',
description: 'Production-ready REST API built with ElysiaJS 1.4 and Drizzle ORM'
},
tags: [
{ name: 'auth', description: 'Authentication endpoints' },
{ name: 'posts', description: 'Blog post management' }
]
}
})
)
// Inject DB into context for all routes
.decorate('db', db)
// Global error handler
.onError(({ code, error, set }) => {
if (code === 'VALIDATION') {
set.status = 422
return { error: 'Validation failed', details: error.message }
}
if (code === 'NOT_FOUND') {
set.status = 404
return { error: 'Resource not found' }
}
console.error(error)
set.status = 500
return { error: 'Internal server error' }
})
.use(authRoutes)
.use(postRoutes)
.listen(process.env.PORT ?? 3000)
console.log(`🦊 ElysiaJS API running at http://localhost:${app.server?.port}`)
export type App = typeof app
Auth Routes with JWT
Create src/routes/auth.ts:
import { Elysia, t } from 'elysia'
import { eq } from 'drizzle-orm'
import { randomBytes, scryptSync, timingSafeEqual } from 'crypto'
import { db } from '../database'
import { users } from '../database/schema'
import { CreateUserBody, LoginBody, UserResponse } from '../validators'
// Simple JWT helper (use @elysiajs/jwt in production for full features)
const signToken = (payload: object): string => {
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url')
const body = Buffer.from(JSON.stringify({ ...payload, iat: Date.now(), exp: Date.now() + 7 * 24 * 60 * 60 * 1000 })).toString('base64url')
const { createHmac } = require('crypto')
const sig = createHmac('sha256', process.env.JWT_SECRET!).update(`${header}.${body}`).digest('base64url')
return `${header}.${body}.${sig}`
}
const hashPassword = (password: string, salt: string): string => {
return scryptSync(password, salt, 64).toString('hex')
}
export const authRoutes = new Elysia({ prefix: '/auth', tags: ['auth'] })
.post(
'/register',
async ({ body, set }) => {
// Check for duplicate email/username
const existing = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, body.email))
.limit(1)
if (existing.length > 0) {
set.status = 409
return { error: 'Email already registered' }
}
const salt = randomBytes(32).toString('hex')
const hashedPassword = hashPassword(body.password, salt)
const [newUser] = await db
.insert(users)
.values({
username: body.username,
email: body.email,
password: hashedPassword,
salt
})
.returning({
id: users.id,
username: users.username,
email: users.email,
isActive: users.isActive,
createdAt: users.createdAt,
updatedAt: users.updatedAt
})
set.status = 201
return { user: newUser, token: signToken({ userId: newUser.id }) }
},
{
body: CreateUserBody,
detail: { summary: 'Register a new user' }
}
)
.post(
'/login',
async ({ body, set }) => {
const [user] = await db
.select()
.from(users)
.where(eq(users.email, body.email))
.limit(1)
if (!user) {
set.status = 401
return { error: 'Invalid credentials' }
}
const hash = hashPassword(body.password, user.salt)
const isValid = timingSafeEqual(
Buffer.from(hash),
Buffer.from(user.password)
)
if (!isValid) {
set.status = 401
return { error: 'Invalid credentials' }
}
return {
token: signToken({ userId: user.id }),
user: {
id: user.id,
username: user.username,
email: user.email
}
}
},
{
body: LoginBody,
detail: { summary: 'Login and get JWT token' }
}
)
Posts Routes with Full CRUD
Create src/routes/posts.ts:
import { Elysia, t } from 'elysia'
import { eq, desc, count, sql } from 'drizzle-orm'
import { db } from '../database'
import { posts, users } from '../database/schema'
import { CreatePostBody, UpdatePostBody, PostIdParam, PaginationQuery } from '../validators'
export const postRoutes = new Elysia({ prefix: '/posts', tags: ['posts'] })
// List posts with pagination
.get(
'/',
async ({ query }) => {
const page = query.page ?? 1
const limit = query.limit ?? 20
const offset = (page - 1) * limit
const [allPosts, [{ total }]] = await Promise.all([
db
.select({
id: posts.id,
title: posts.title,
slug: posts.slug,
published: posts.published,
viewCount: posts.viewCount,
createdAt: posts.createdAt,
author: {
id: users.id,
username: users.username
}
})
.from(posts)
.leftJoin(users, eq(posts.authorId, users.id))
.where(eq(posts.published, true))
.orderBy(desc(posts.createdAt))
.limit(limit)
.offset(offset),
db.select({ total: count() }).from(posts).where(eq(posts.published, true))
])
return {
data: allPosts,
pagination: {
page,
limit,
total: Number(total),
pages: Math.ceil(Number(total) / limit)
}
}
},
{
query: PaginationQuery,
detail: { summary: 'List published posts with pagination' }
}
)
// Get single post by ID (and increment view count)
.get(
'/:id',
async ({ params, set }) => {
const [post] = await db
.update(posts)
.set({ viewCount: sql`${posts.viewCount} + 1` })
.where(eq(posts.id, params.id))
.returning()
if (!post) {
set.status = 404
return { error: 'Post not found' }
}
return { data: post }
},
{
params: PostIdParam,
detail: { summary: 'Get a post by ID (increments view count)' }
}
)
// Create post
.post(
'/',
async ({ body, set }) => {
// In production, extract authorId from JWT via middleware
const authorId = 'demo-author-id'
const [newPost] = await db
.insert(posts)
.values({ ...body, authorId })
.returning()
set.status = 201
return { data: newPost }
},
{
body: CreatePostBody,
detail: { summary: 'Create a new post' }
}
)
// Update post
.patch(
'/:id',
async ({ params, body, set }) => {
const [updated] = await db
.update(posts)
.set({ ...body, updatedAt: new Date() })
.where(eq(posts.id, params.id))
.returning()
if (!updated) {
set.status = 404
return { error: 'Post not found' }
}
return { data: updated }
},
{
params: PostIdParam,
body: UpdatePostBody,
detail: { summary: 'Update a post' }
}
)
// Delete post
.delete(
'/:id',
async ({ params, set }) => {
const [deleted] = await db
.delete(posts)
.where(eq(posts.id, params.id))
.returning({ id: posts.id })
if (!deleted) {
set.status = 404
return { error: 'Post not found' }
}
set.status = 204
},
{
params: PostIdParam,
detail: { summary: 'Delete a post' }
}
)
End-to-End Type Safety with Eden Treaty
One of ElysiaJS's killer features is Eden Treaty — a client library that infers your entire API's types automatically. Add it to your frontend:
bun add elysia @elysiajs/eden
// frontend/api.ts
import { treaty } from '@elysiajs/eden'
import type { App } from '../api/src/index'
const api = treaty<App>('localhost:3000')
// Fully typed — TypeScript knows the response shape
const { data, error } = await api.posts.get({
query: { page: 1, limit: 10 }
})
// data.data[0].title → TypeScript knows this exists
// data.pagination.total → TypeScript knows this too
No code generation step. No GraphQL schema. Just TypeScript inference across your entire stack.
Running in Production
Add these scripts to package.json:
{
"scripts": {
"dev": "bun run --watch src/index.ts",
"build": "bun build src/index.ts --target bun --outdir dist",
"start": "bun dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
}
}
For production, compile to a single binary (this is what gets you to 290k+ req/s):
bun build src/index.ts --compile --outfile server
./server
The --compile flag bundles your entire app into a single executable that doesn't need Bun installed on the target machine. This is what the ElysiaJS benchmark uses — it's not just a raw framework number, it's your realistic production performance.
Performance Benchmarks: What to Expect
Based on the November 2025 benchmarks on Intel i7-13700K:
| Scenario | Req/s |
|---|---|
| ElysiaJS (no validation) | 293,991 |
| ElysiaJS (with validation) | 223,924 |
| Encore (Rust-powered, no validation) | 139,033 |
| Encore (with validation) | 95,854 |
ElysiaJS's JIT compiler pre-computes validation logic at startup — when a request arrives, most of the type-checking work is already done. That's why adding validation barely impacts throughput.
For comparison: a standard Express.js app on Node.js typically handles 15,000–25,000 req/s for similar workloads. ElysiaJS + Bun is roughly 10–15x faster.
Production Checklist
Before going live with this stack, verify:
- [ ] Rate limiting — add
@elysiajs/rate-limitfor per-IP throttling - [ ] JWT middleware — use
@elysiajs/jwtfor production-grade token handling - [ ] CORS — configure
@elysiajs/corsfor your allowed origins - [ ] Helmet-equivalent headers — set security headers in a global middleware
- [ ] Migration CI step — run
drizzle-kit migratein your deployment pipeline before app start - [ ] Connection pool tuning — adjust
maxconnections based on your Postgres plan - [ ] Health check endpoint — add
GET /healththat pings the database - [ ] Structured logging — ElysiaJS supports Pino via the lifecycle hooks
Where to Go Next
This API stack pairs naturally with 1xAPI if you're building monetized APIs — the same pattern works for any data-driven API product you want to expose on RapidAPI or sell directly.
For the OpenAPI docs (available at http://localhost:3000/swagger), the @elysiajs/swagger plugin auto-generates from your TypeBox validators — no manual documentation needed.
Drizzle Studio (bun run db:studio) gives you a web UI to browse your data at https://local.drizzle.studio — handy during development.
Conclusion
The ElysiaJS 1.4 + Drizzle ORM stack solves the three biggest pain points in TypeScript API development:
- Performance — Bun's runtime + ElysiaJS's JIT compiler gets you 200k+ req/s without exotic infrastructure
-
Type safety — Your database schema drives your API validators via
drizzle-typebox, making schema drift a compile-time error instead of a production incident - Developer experience — Eden Treaty brings those types to your frontend without code generation or a separate schema format
In 2026, there's no reason to accept runtime type errors between your database, API, and frontend. This stack eliminates them at every layer.
Top comments (0)