A good API framework saves you not the time at the start, but the time of rework. As soon as validation, TypeScript types, and documentation live in three different places, they start to drift apart: a field gets renamed in the code, but Swagger keeps the old name while validation already uses the new one. At PROWEB we build backends with Bun + Elysia + Drizzle, where there is a single schema: it validates the input, types the handler, and lands in OpenAPI. In this article we show how to assemble such an API — with code and production experience.
Why Bun
Bun is a JavaScript and TypeScript runtime built on the JavaScriptCore engine, with a toolkit around it: a package manager, a test runner, and built-in .env support.
- It runs TypeScript directly — no ts-node, no tsx, no separate build steps
- bun install installs dependencies quickly and writes the
bun.locklockfile - The built-in bun:test runner means tests without setting up Vitest or Jest
- It is Node-compatible: most npm packages work without changes
bun init -y
bun add elysia @elysiajs/openapi drizzle-orm postgres
bun add -d drizzle-kit @types/bun
bun --hot src/index.ts # dev server with hot reloadThe only area to be careful with is native modules: if a library pulls in a Node addon, check that it works under Bun before it ends up at the center of your architecture.
Elysia: an HTTP layer where validation is the types
Elysia is a web framework designed for Bun and end-to-end type safety. A minimal server with live OpenAPI documentation looks like this:
// src/index.ts
import { Elysia } from 'elysia'
import { openapi } from '@elysiajs/openapi'
const app = new Elysia()
.use(openapi())
.get('/health', () => ({ status: 'ok' }))
.listen(3000)
console.log(`Docs: http://localhost:${app.server?.port}/openapi`)Schemas are written in the TypeBox style via t and attached to a route with three fields: body (the request body), params / query (path and query string), and response (the response, per status code). One schema solves three problems at once:
import { Elysia, t } from 'elysia'
const TaskInput = t.Object({
title: t.String({ minLength: 1, maxLength: 200 })
})
const Task = t.Object({
id: t.String({ format: 'uuid' }),
title: t.String(),
done: t.Boolean(),
createdAt: t.String({ format: 'date-time' })
})
new Elysia().post('/tasks', ({ body, set }) => {
// body is already typed: { title: string }
set.status = 201
return createTask(body)
}, {
body: TaskInput, // request validation
response: { 201: Task }, // and response shape control
detail: { summary: 'Create a task', tags: ['tasks'] }
})- An invalid request is rejected by Elysia itself — with a 422 code and a description, no manual ifs
- Inside the handler,
bodyis already typed by the schema — no casting needed - response is not paranoia: it strips extra fields from the response and lands in OpenAPI
- detail — the route's summary, tags, and description go straight into the documentation
Cross-cutting logic is assembled into plugins. Authorization is the classic example: derive reads the token from the header, verifies it, and puts the session into the context; routes that include the plugin simply read session:
// src/plugins/auth.ts
import { Elysia } from 'elysia'
export const auth = new Elysia({ name: 'auth' })
.derive(({ headers, error }) => {
const token = headers.authorization?.replace('Bearer ', '')
const session = token ? verifySession(token) : null
if (!session) {
throw error(401, { code: 'UNAUTHORIZED', message: 'Authorization required' })
}
return { session }
})
// src/routes/me.ts
export const meRoutes = new Elysia({ prefix: '/me' })
.use(auth)
.get('/', ({ session }) => getProfile(session.userId))verifySession here is your check (for example, @elysiajs/jwt or your own session table). A throw inside derive interrupts processing — the request never even reaches the route.
Errors are gathered into a single handler: stable codes go outside, details go to the logs.
app.onError(({ code, error, set, request }) => {
if (code === 'VALIDATION') {
set.status = 400
return { code: 'VALIDATION', message: 'Check the request parameters' }
}
console.error(request.url, error)
set.status = 500
return { code: 'INTERNAL', message: 'Internal server error' }
})Drizzle: the database schema as code
Drizzle is a TypeScript ORM without magic: tables are described in code, queries read almost like SQL, and migrations are generated from the schema.
// src/db/schema.ts
import { boolean, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
export const tasks = pgTable('tasks', {
id: uuid('id').defaultRandom().primaryKey(),
title: text('title').notNull(),
done: boolean('done').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
})Note the naming: camelCase in TypeScript, snake_case in SQL — the column name is written explicitly, so the database will not end up with a quoted createdAt.
The connection is the postgres driver wrapped by Drizzle. The module runs once, so the application has a single connection pool:
// src/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from './schema'
const client = postgres(process.env.DATABASE_URL!, { max: 10 })
export const db = drizzle(client, { schema })Migrations go through drizzle-kit: describe the schema, generate the SQL, apply it:
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema.ts',
out: './drizzle',
dbCredentials: { url: process.env.DATABASE_URL! }
})bunx drizzle-kit generate # the SQL migration will appear in ./drizzle
bunx drizzle-kit migrate # apply the migrations- Keep migration files in the repository; drizzle-kit push (applying the schema directly) is best left for prototypes
- In Postgres, .returning() gives back the inserted or updated row — no follow-up SELECT needed
- A transaction is
db.transaction(async (tx) => …), and down the call chain passtx, notdb
Queries read like SQL, but with autocomplete and type checking:
import { desc, eq } from 'drizzle-orm'
const list = await db.select().from(tasks)
.orderBy(desc(tasks.createdAt))
.limit(100)
const [created] = await db.insert(tasks)
.values({ title: 'Build an API on Bun' })
.returning()
await db.update(tasks)
.set({ done: true, updatedAt: new Date() })
.where(eq(tasks.id, id))OpenAPI: documentation that never lags behind the code
The TypeBox schemas you already wrote for validation are turned into OpenAPI by Elysia itself. The plugin only needs the general service info:
app.use(openapi({
documentation: {
info: { title: 'Tasks API', version: '1.0.0' },
tags: [{ name: 'tasks', description: 'Tasks' }]
}
}))The spec then works in two roles:
- Live documentation. The interactive UI at
/openapialways matches the code, because it is assembled from it - A contract for clients. The JSON spec at
/openapi/jsonis downloaded by a script, and a typed client is generated from it — for example, TanStack Query hooks via @hey-api/openapi-ts or plain types via openapi-typescript
If both the server and the client are on Elysia, there is a shorter path — Eden (@elysiajs/eden): a typed client straight from the server's types, with no intermediate spec. We still stick with OpenAPI: it is a universal contract that the frontend, mobile clients, and external integrations all work with — and it does not tie consumers to your framework.
Practical advice: generate the client from a live server (dev or staging), not from a file committed ages ago. The chain is simple: a script downloads the spec → regenerates the client → typecheck. Change a field in the API, and the frontend will not build until it updates its client.
A complete example: the tasks API from start to finish
Let's put it all together — a CRUD with validation, Drizzle, and honest response codes:
// src/routes/tasks.ts
import { Elysia, t } from 'elysia'
import { desc, eq } from 'drizzle-orm'
import { db } from '../db'
import { tasks } from '../db/schema'
const Task = t.Object({
id: t.String({ format: 'uuid' }),
title: t.String(),
done: t.Boolean(),
createdAt: t.String({ format: 'date-time' })
})
const TaskInput = t.Object({ title: t.String({ minLength: 1, maxLength: 200 }) })
const TaskPatch = t.Object({
title: t.Optional(t.String({ minLength: 1, maxLength: 200 })),
done: t.Optional(t.Boolean())
})
const ErrorDto = t.Object({ code: t.String(), message: t.String() })
// Date fields are returned as ISO strings: predictable in JSON, honest in the spec
const toDto = (row: typeof tasks.$inferSelect) => ({
id: row.id,
title: row.title,
done: row.done,
createdAt: row.createdAt.toISOString()
})
export const taskRoutes = new Elysia({ prefix: '/tasks', tags: ['tasks'] })
.get('/', async () => {
const rows = await db.select().from(tasks)
.orderBy(desc(tasks.createdAt))
.limit(100)
return rows.map(toDto)
}, {
response: t.Array(Task),
detail: { summary: 'List tasks' }
})
.get('/:id', async ({ params, error }) => {
const [row] = await db.select().from(tasks).where(eq(tasks.id, params.id))
if (!row) return error(404, { code: 'NOT_FOUND', message: 'Task not found' })
return toDto(row)
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
response: { 200: Task, 404: ErrorDto },
detail: { summary: 'Get a task' }
})
.post('/', async ({ body, set }) => {
const [row] = await db.insert(tasks)
.values({ title: body.title })
.returning()
set.status = 201
return toDto(row)
}, {
body: TaskInput,
response: { 201: Task },
detail: { summary: 'Create a task' }
})
.patch('/:id', async ({ params, body, error }) => {
const patch: Partial<typeof tasks.$inferInsert> = { updatedAt: new Date() }
if (body.title !== undefined) patch.title = body.title
if (body.done !== undefined) patch.done = body.done
const [row] = await db.update(tasks)
.set(patch)
.where(eq(tasks.id, params.id))
.returning()
if (!row) return error(404, { code: 'NOT_FOUND', message: 'Task not found' })
return toDto(row)
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
body: TaskPatch,
response: { 200: Task, 404: ErrorDto },
detail: { summary: 'Update a task' }
})
.delete('/:id', async ({ params, set }) => {
await db.delete(tasks).where(eq(tasks.id, params.id))
set.status = 204
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
detail: { summary: 'Delete a task' }
})// src/index.ts
import { Elysia } from 'elysia'
import { openapi } from '@elysiajs/openapi'
import { taskRoutes } from './routes/tasks'
const app = new Elysia()
.use(openapi({
documentation: {
info: { title: 'Tasks API', version: '1.0.0' },
tags: [{ name: 'tasks', description: 'Tasks' }]
}
}))
.onError(({ code, error, set, request }) => {
if (code === 'VALIDATION') {
set.status = 400
return { code: 'VALIDATION', message: 'Check the request parameters' }
}
console.error(request.url, error)
set.status = 500
return { code: 'INTERNAL', message: 'Internal server error' }
})
.use(taskRoutes)
.listen(3000)
export { app }A small detail that shows the value of schemas: :id is declared as uuid — a malformed identifier is rejected by validation with a 422 before it ever reaches Postgres, instead of crashing with a 500 inside the driver.
Production practices
Tests without a network. An Elysia app can be driven with a plain Request through app.handle — no port, no sockets:
// test/tasks.test.ts
import { describe, expect, it } from 'bun:test'
import { app } from '../src/index'
describe('POST /tasks', () => {
it('creates a task', async () => {
const res = await app.handle(new Request('http://localhost/tasks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: 'The first task' })
}))
expect(res.status).toBe(201)
expect((await res.json()).title).toBe('The first task')
})
it('rejects an empty title', async () => {
const res = await app.handle(new Request('http://localhost/tasks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: '' })
}))
expect(res.status).toBe(422)
})
})- For integration tests — a separate database or Testcontainers; handlers can be tested without one
- Docker: the
oven/bunbase image andbun install --frozen-lockfile; if you want a single artifact —bun build --compileproduces a self-contained binary - Observability: pino for structured logs, a
requestIdin every response, and/healthfor the load balancer - Hygiene: a body size limit, rate limiting on public endpoints, CORS via
@elysiajs/corsif the frontend lives on another domain - Migrations — a separate deployment step before the app starts, not "on the first request"
A story from practice
The server of one of our products is built on exactly this stack. It serves a live spec; a script in the monorepo downloads it and generates a typed client (TanStack Query) for three frontends at once — the website, the customer dashboard, and the admin panel. When we change the API — rename a field or tighten a type — the frontend builds fail the typecheck before deployment: the client already knows the new field, and the old code is incompatible with it. In this setup the documentation is not maintained separately — it simply cannot fall behind.
When to choose something else
- NestJS — a large team that needs an architecture with DI, modules, and guards out of the box
- Fastify — when the plugin ecosystem is critical; it also runs under Bun, if you want to change the runtime but not the framework
- Hono — if you target edge environments and one codebase across several runtimes
In other cases, the Bun + Elysia + Drizzle combo gives an unusually short path from "described a schema" to "validation works, types line up, docs are ready, client generated".
In short
A single TypeBox schema in Elysia gives you validation, TypeScript types, and OpenAPI; Drizzle keeps the database schema in code and generates migrations; the typed client is assembled from the live server spec. Code and documentation stop drifting apart — because they are one and the same code.
