Type-Safe REST API with Hono + Bun + Drizzle — One DB Schema All the Way to the Frontend
When building backend APIs, you inevitably hit this situation: you rename a single DB column — user_name → username — TypeScript shows no errors, but then undefined starts flooding in at runtime. Turns out you had to manually keep four places in sync: the ORM model, the request validation schema, the API response type, and the frontend fetch code — and you missed one. This is a pattern that's burned me more than once or twice.
The key to the Hono + Bun + Drizzle stack is that a single DB schema definition flows consistently through four layers: request validation → API handler return values → frontend RPC client. Compared to the traditional Node.js + Express + Prisma combination, the biggest difference is that the act of "having to align types" is structurally eliminated. It's not about reducing the chances of types breaking — it's about removing the very place where they can break. In this article, I'll walk through how these three tools interlock, and honestly share where you get stuck when you actually put them together.
If you've used the Node.js + Express + Prisma combination before, the comparison will come through much more intuitively.
Core Concepts
The Type Flow These Three Tools Create — See the Destination First
Before explaining each tool individually, you can see at a glance what this stack aims for.
Drizzle Schema (table definitions)
↓ ($inferSelect / $inferInsert)
TypeScript Types (auto-extracted)
↓ (drizzle-zod)
Zod Validation Schema (auto-generated)
↓ (@hono/zod-validator)
Hono Handler — request body is type-safe
↓ (c.json())
API Response Type Inference
↓ (export typeof route)
Hono RPC Client — autocomplete + return typesWhen a column name or type changes, the compiler pinpoints every affected location in this chain at once. That is the core value this stack delivers. Now let's look at each tool.
Hono — An Ultra-Lightweight Web Framework That Runs Anywhere
Hono is a web framework based on Web Standards (Fetch API). Its core bundle is only 14KB, and the same code runs identically on Node.js, Bun, Deno, and Cloudflare Workers.
The most noticeable difference compared to Express is the built-in RPC feature. Using the hc() client, you can consume the types exported directly from the server router — no separate code generation step required.
| Comparison | Express | Hono |
|---|---|---|
| Bundle Size | ~500KB | ~14KB |
| Built-in RPC | No | Yes (hc()) |
| Runtime Portability | Node.js only | All runtimes |
| TypeScript Support | Requires separate config | Built-in |
How does Hono RPC differ from tRPC and gRPC? — tRPC is a similar approach for building type-safe APIs in TypeScript projects, but it requires a separate adapter to expose them as REST HTTP. gRPC is a binary Protobuf-based protocol with strong cross-language compatibility, but it's cumbersome to use directly from a browser. Hono RPC keeps HTTP REST endpoints intact while sharing only TypeScript types. It's best suited when you want to preserve compatibility with existing REST clients while simply layering on type safety.
Bun — An All-in-One JavaScript Runtime
Bun is a runtime based on the JavaScriptCore engine that bundles together server execution, a bundler, a package manager, and a test runner into a single tool. If you've managed the ts-node + esbuild + npm combination separately in a Node.js environment, you'll immediately grasp what it means to resolve that dependency puzzle in one shot.
| Feature | Node.js Ecosystem | Bun |
|---|---|---|
| Runtime | Node.js | Bun |
| Package Management | npm / pnpm / yarn | bun (built-in) |
| Bundler | esbuild / webpack | bun build (built-in) |
| TypeScript Execution | ts-node / tsx | Native support |
| Testing | Jest / Vitest | bun test (built-in) |
| SQLite | separate better-sqlite3 install | Built-in driver |
bun install records package installation speeds 7–25x faster than npm in a clean install environment with no cache (based on Strapi benchmarks). This difference is noticeable in CI pipelines. However, in real API server performance, DB I/O becomes the bottleneck and this gap narrows considerably — it's more realistic to view the main reason for choosing this stack as developer experience and operational simplicity rather than performance.
Drizzle ORM — A Type-Safe ORM That Doesn't Hide SQL
Drizzle is a library with the philosophy of "being an ORM without hiding SQL." Unlike Prisma, which defines schemas in its own SDL, Drizzle writes schemas in TypeScript code and infers types directly from there.
// schema.ts — based on Bun built-in SQLite
// For PostgreSQL, apply the same approach with pgTable from drizzle-orm/pg-core
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
export const posts = sqliteTable('posts', {
id: integer('id').primaryKey({ autoIncrement: true }),
title: text('title').notNull(),
content: text('content'),
})
// Auto-extract TypeScript types from the schema
export type Post = typeof posts.$inferSelect
export type NewPost = typeof posts.$inferInsert
// Zod validation schema is also auto-generated — add business rules in the second argument
export const insertPostSchema = createInsertSchema(posts, {
title: (schema) => schema.title.min(1).max(100),
})
export const selectPostSchema = createSelectSchema(posts)
$inferSelect/$inferInsert— Helpers that Drizzle uses to analyze the table definition and automatically create TypeScript types suited to SELECT results and INSERT inputs respectively. Whether a column isnotNull(), whetherautoIncrementis set, and so on are all reflected — so columns likeidbeing treated as optional in the INSERT type is handled automatically.
The main reason Drizzle is chosen over Prisma in serverless and edge environments is the cold start problem. I actually had the experience of deploying Prisma on Vercel Edge, getting burned by initialization time, and then migrating to Drizzle. Prisma must include a Rust-written binary query engine in the bundle, which significantly increases function size. Drizzle is pure TypeScript, so this problem doesn't exist.
Practical Application
Example 1: Setting Up a Post CRUD API
Let's first set the file structure when starting a project from scratch.
src/
├── db.ts # Drizzle client initialization
├── schema.ts # Single source of truth
├── app.ts # Hono router
└── index.ts # Entry point// db.ts
import { drizzle } from 'drizzle-orm/bun-sqlite'
import { Database } from 'bun:sqlite'
const sqlite = new Database('dev.db')
export const db = drizzle(sqlite)// app.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { db } from './db'
import { posts, insertPostSchema } from './schema'
import { eq } from 'drizzle-orm'
const app = new Hono()
const postsRoute = app
.get('/posts', async (c) => {
const result = await db.select().from(posts)
return c.json(result) // Post[] type is auto-inferred
})
.get(
'/posts/:id',
// Using z.coerce.number() for parsing prevents NaN from passing through silently
zValidator('param', z.object({ id: z.coerce.number() })),
async (c) => {
const { id } = c.req.valid('param') // number type is guaranteed
const [post] = await db.select().from(posts).where(eq(posts.id, id))
if (!post) return c.json({ error: 'Not found' }, 404)
return c.json(post)
}
)
.post('/posts', zValidator('json', insertPostSchema), async (c) => {
const body = c.req.valid('json') // validated, type-safe body
const [created] = await db.insert(posts).values(body).returning()
return c.json(created, 201)
})
// Exporting postsRoute instead of app:
// The router created by chaining .get().post() carries per-endpoint type information,
// so AppType accurately reflects the autocomplete list for the RPC client
export type AppType = typeof postsRoute
export default app| Code Location | Role |
|---|---|
zValidator('param', z.object({ id: z.coerce.number() })) |
Handles path parameter parsing + NaN prevention simultaneously |
zValidator('json', insertPostSchema) |
Handles runtime request validation + type inference simultaneously |
c.req.valid('json') |
Type-safe access guaranteed to be validated |
export type AppType |
Type sharing entry point for the RPC client |
Example 2: Connecting the Frontend RPC Client
With a monorepo structure or TypeScript Project References, you can import server types directly. At first it seems too good to be true, but when you actually use it, autocomplete catches everything — endpoint lists, parameter types, and response types.
// client.ts (frontend)
import { hc } from 'hono/client'
import type { AppType } from '../server/app' // import type only
const client = hc<AppType>('http://localhost:3000')
// GET /posts — autocomplete + Post[] return type
const postsRes = await client.posts.$get()
const posts = await postsRes.json() // Post[]
// GET /posts/:id — parameter type checking
const postRes = await client.posts[':id'].$get({
param: { id: '1' }
})
// POST /posts — request body type checking
const createRes = await client.posts.$post({
json: { title: 'New Post', content: 'Content' }
})
const newPost = await createRes.json() // PostDoes
import typeactually affect bundle size? — The short answer is yes. If you import server code with a regularimportinstead ofimport type, esbuild or Vite will try to include the DB connection code (bun:sqlite,drizzle-orm) in the client bundle. Since these are native modules that can't run in a browser environment, it sometimes leads to build errors. Once you've experienced this mistake, you'll makeimport typea habit.
It's also good to be aware of the limits of type inference you'll actually encounter when using Hono RPC. There are cases where TypeScript inference breaks when branching responses with union types (if (condition) return c.json(typeA) else return c.json(typeB)) or when composing nested routers with app.route(). In these cases, explicitly specifying response types or maintaining a simpler router structure is better for preserving RPC type safety.
Example 3: Automatic OpenAPI Document Generation (An Independent Alternative Approach)
This example is an independent alternative to the earlier app.ts. Since using OpenAPIHono instead of Hono creates a different type hierarchy, it's best to choose one of the two approaches early in the project. Adding @hono/zod-openapi automatically generates OpenAPI 3.1 documentation from Zod schema definitions.
import { OpenAPIHono, createRoute } from '@hono/zod-openapi'
import { selectPostSchema, insertPostSchema } from './schema'
import { db } from './db'
import { posts } from './schema'
const app = new OpenAPIHono()
const createPostRoute = createRoute({
method: 'post',
path: '/posts',
request: {
body: { content: { 'application/json': { schema: insertPostSchema } } },
},
responses: {
201: {
content: { 'application/json': { schema: selectPostSchema } },
description: 'Created post',
},
},
})
app.openapi(createPostRoute, async (c) => {
const body = c.req.valid('json')
const [post] = await db.insert(posts).values(body).returning()
return c.json(post, 201)
})
// Serves OpenAPI JSON at /doc and Scalar UI at /ui
app.doc('/doc', { openapi: '3.1.0', info: { title: 'Posts API', version: '1.0' } })Adding Scalar instead of Swagger UI gives you a much better-looking API documentation page.
Pros and Cons Analysis
Advantages
| Item | Details |
|---|---|
| End-to-end type safety | When DB schema changes, the compiler immediately flags every affected handler |
| No code generation | Unlike tRPC or GraphQL, types are shared without a separate generation step |
| Operational simplicity | Bun's All-in-One structure minimizes build tool dependencies |
| Automatic API documentation | OpenAPI docs auto-generated via @hono/zod-openapi + Scalar |
| Runtime portability | Deploy to Cloudflare Workers, Vercel Edge without code changes |
| Serverless-friendly | Pure TypeScript base means no cold start burden |
Among these, "end-to-end type safety" is what you feel most in practice. The experience of the IDE showing every affected point at once when you rename a DB column — once you've had it, you won't want to go back.
Drawbacks and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Real-world performance gap narrows | Synthetic benchmark 4x difference shrinks significantly in real environments including DB | Reframe selection rationale around DX and operational simplicity rather than performance |
| Zod type mixing complexity | Native types and Zod types coexist in large codebases | Use drizzle-zod to consolidate type extraction in a single schema.ts |
| Complex query limitations | Benefits diminish in domains with heavy aggregation or polymorphic relationships | Use raw SQL alongside for complex queries |
| Bun compatibility | Some native Node.js modules are not supported | Verify dependencies before adoption; use Node.js compatibility mode if needed |
| Monorepo required | Hono RPC type sharing is smoothest in the same repo or with Project References | Start with a monorepo from the beginning, or separate into a types package |
| RPC type inference limits | Inference breaks in some cases with union response types or nested router composition | Keep router structure simple or explicitly annotate response types |
Honestly, "monorepo required" is the part that comes up most often when introducing this to a team. Restructuring a repo just for RPC type sharing when there's already a separate REST API client isn't an easy decision. If you're already running a monorepo or starting a project from scratch, the advantages of this stack come through fully.
The Most Common Mistakes in Practice
-
Using
insertPostSchemaas-is without additional validation — The base schema generated bycreateInsertSchemaonly reflects DB constraints (notNull, type). Business rules (minimum length, allowed value ranges, etc.) should be added directly in the second-argument callback. Keeping them all in one place inschema.tsmakes them easy to find later. -
Importing
AppTypewith a regularimport— If you import server modules from the frontend with a regularimportinstead ofimport type, DB connection code can end up in the client bundle. Even if there's no build error, the bundle size grows unnecessarily. -
Taking benchmark numbers as direct expectations — "Bun is 4x faster than Node.js" is a synthetic benchmark figure. In real applications, DB I/O and JSON serialization become the bottleneck and the gap narrows significantly. When introducing this stack to a team, leading with performance numbers can lead to disappointment later — presenting developer experience and operational simplicity as the primary reasons is far more persuasive.
Wrapping Up
The real value of the Hono + Bun + Drizzle stack lies not in speed, but in the structural safety net where "when you change one schema, the compiler tells you everything that needs to be fixed."
The fastest way to experience it directly is to start with a small project.
-
Install Bun and initialize the project — Follow the official installation guide to install Bun, then generate a Hono starter project with
bun create hono my-app. Selectbunwhen prompted for the runtime. -
Write a Drizzle schema and connect
drizzle-zod— Runbun add drizzle-orm drizzle-zod @hono/zod-validator, define your tables inschema.ts, and pull out a Zod schema withcreateInsertSchema— the type flow will be immediately visible. -
Change the schema and experience the type safety net firsthand — Export
AppType, create a client withhc<AppType>()on the frontend, and then rename a schema column. The experience of the compiler instantly telling you what needs to be fixed all the way down to the frontend code is the most direct explanation of why you'd choose this stack.
References
- Hono Official Docs — Getting Started with Bun
- Hono RPC Official Guide
- Drizzle ORM Official Site
- Drizzle ORM Zod Integration Docs
- Why Bun + Hono + Drizzle + SQLite is the Ultimate Stack for AI Agent Development | Zenn
- Drizzle + Zod: A Type-Safe API in 200 Lines | DEV Community
- Build a documented type-safe API with Hono, Drizzle, Zod, OpenAPI and Scalar | Syntax.fm
- Hono RPC vs tRPC vs ts-rest: Type-Safe APIs 2026 | PkgPulse
- Bun vs Node.js in 2026: Benchmarks & Migration Guide | Strapi
- Hono — Ending the TypeScript War Between Frontend and Backend | Medium