This is the TypeScript backend stack I reach for in 2026: Bun + Hono + Drizzle + Zod. Small, fast, type-safe end-to-end, no build step, no decorators, no magic. If you’ve been waiting for a TypeScript stack that feels as clean as a Go server, this is it.
This post is a complete walk-through with a production-ready layout.
Why this stack
- Bun — runtime, package manager, bundler, test runner. One binary.
- Hono — small (~14kB) routing framework. Edge-compatible. The Express that types correctly.
- Drizzle — the ORM. Type-safe SQL builder. No code generation. No magic.
- Zod — runtime validation that becomes TypeScript types.
End-to-end type inference from request → handler → DB → response. The compiler tells you when an API contract breaks.
Project setup
bun init my-api && cd my-api
bun add hono zod drizzle-orm postgres
bun add -D drizzle-kit @types/bun
A starting package.json:
{
"name": "my-api",
"type": "module",
"scripts": {
"dev": "bun --hot src/index.ts",
"build": "bun build src/index.ts --outdir dist --target node",
"test": "bun test",
"typecheck": "tsc --noEmit",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
}
}
bun --hot is HMR for the server. Save a file, the app reloads, state preserved. bun build produces a single bundled file you can run on bare Node, deploy to Lambda, or ship in a Docker image.
Project layout
src/
├── index.ts # entry: wires app + serve
├── env.ts # typed env via Zod
├── db/
│ ├── client.ts # postgres + drizzle
│ └── schema.ts # tables and relations
├── routes/
│ ├── users.ts
│ └── health.ts
├── lib/
│ ├── error.ts # AppError, error handler
│ └── log.ts # structured logging
└── services/
└── users.ts # business logic
The same shape as the FastAPI production layout — convergent good ideas.
Typed env
// src/env.ts
import { z } from "zod";
const Env = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().default(8000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
export const env = Env.parse(process.env);
If DATABASE_URL is missing, the app fails at startup, not at the first DB call. The type checker also knows env.PORT is number. No string-to-number bugs.
Database with Drizzle
// src/db/schema.ts
import { pgTable, serial, text, timestamp, boolean, uniqueIndex } from "drizzle-orm/pg-core";
export const users = pgTable(
"users",
{
id: serial("id").primaryKey(),
email: text("email").notNull(),
fullName: text("full_name").notNull(),
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({ emailIdx: uniqueIndex("users_email_idx").on(t.email) })
);
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
Two derived types come for free. Use them everywhere downstream.
// src/db/client.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { env } from "../env";
const client = postgres(env.DATABASE_URL, { max: 10 });
export const db = drizzle(client, { schema: { users } });
export type DB = typeof db;
postgres (postgres.js) is the connection driver. It’s stupid-fast, ESM-native, and pairs perfectly with Drizzle.
Validation + type inference
// src/routes/users.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { db } from "../db/client";
import { users } from "../db/schema";
import { eq } from "drizzle-orm";
import { AppError } from "../lib/error";
const CreateUser = z.object({
email: z.string().email(),
fullName: z.string().min(1).max(120),
});
const app = new Hono();
app.post("/", zValidator("json", CreateUser), async (c) => {
const payload = c.req.valid("json"); // type-inferred from Zod
const exists = await db.query.users.findFirst({ where: eq(users.email, payload.email) });
if (exists) throw new AppError("conflict", `email ${payload.email} taken`, 409);
const [user] = await db
.insert(users)
.values({ email: payload.email, fullName: payload.fullName })
.returning();
return c.json(user, 201);
});
app.get("/:id", async (c) => {
const id = Number(c.req.param("id"));
const user = await db.query.users.findFirst({ where: eq(users.id, id) });
if (!user) throw new AppError("not_found", `user ${id}`, 404);
return c.json(user);
});
export default app;
Notice:
zValidator("json", schema)validates and types the body.c.req.valid("json")is fully typed —payload.emailisstring.- Drizzle queries are typed — the IDE autocompletes
users.email. .returning()gives you the inserted row, typed.
Error handling
// src/lib/error.ts
export class AppError extends Error {
constructor(
public code: string,
public message: string,
public statusCode: number = 500,
public extra: Record<string, unknown> = {}
) {
super(message);
}
}
// src/index.ts
import { Hono } from "hono";
import { logger } from "hono/logger";
import { compress } from "hono/compress";
import { secureHeaders } from "hono/secure-headers";
import users from "./routes/users";
import health from "./routes/health";
import { AppError } from "./lib/error";
import { env } from "./env";
const app = new Hono();
app.use("*", logger());
app.use("*", compress());
app.use("*", secureHeaders());
app.route("/users", users);
app.route("/healthz", health);
app.onError((err, c) => {
if (err instanceof AppError) {
return c.json(
{ error: err.code, message: err.message, ...err.extra },
err.statusCode as 400 | 401 | 403 | 404 | 409 | 500
);
}
console.error(err);
return c.json({ error: "internal_error", message: "internal" }, 500);
});
export default {
port: env.PORT,
fetch: app.fetch,
};
Bun’s default export { port, fetch } syntax is its own server pattern — concise, no Bun.serve(...) boilerplate.
hono/logger gives you request logs. hono/compress gzip/br. hono/secure-headers sets CSP, HSTS, X-Frame-Options, etc.
Health check
// src/routes/health.ts
import { Hono } from "hono";
import { sql } from "drizzle-orm";
import { db } from "../db/client";
const app = new Hono();
app.get("/", async (c) => {
try {
await db.execute(sql`SELECT 1`);
return c.json({ ok: true });
} catch {
return c.json({ ok: false }, 503);
}
});
export default app;
A health check that pings the DB. Anything else is decoration.
Testing
// tests/users.test.ts
import { describe, test, expect, beforeAll } from "bun:test";
import app from "../src/index";
describe("POST /users", () => {
test("creates a user", async () => {
const res = await app.fetch(
new Request("http://localhost/users", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: "[email protected]", fullName: "A B" }),
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.email).toBe("[email protected]");
});
test("rejects bad email", async () => {
const res = await app.fetch(
new Request("http://localhost/users", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: "not-an-email", fullName: "A B" }),
})
);
expect(res.status).toBe(400);
});
});
Hono apps are Request → Response functions — testable in-process without spinning a server. Bun’s test runner runs this in milliseconds.
OpenAPI
Hono’s RPC (hono/client) gives you typed clients out of the box, but for traditional OpenAPI:
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
const app = new OpenAPIHono();
const route = createRoute({
method: "post",
path: "/users",
request: { body: { content: { "application/json": { schema: CreateUser } } } },
responses: {
201: { description: "Created", content: { "application/json": { schema: UserSchema } } },
400: { description: "Validation error" },
409: { description: "Conflict" },
},
});
app.openapi(route, async (c) => { /* handler */ });
app.doc("/openapi.json", { openapi: "3.1.0", info: { title: "API", version: "1" } });
Now you have an OpenAPI spec generated from your code. Drop in Swagger UI:
import { swaggerUI } from "@hono/swagger-ui";
app.get("/docs", swaggerUI({ url: "/openapi.json" }));
Free interactive docs.
Deploying
Bun-native
FROM oven/bun:1.3-alpine
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile --production
COPY . .
EXPOSE 8000
CMD ["bun", "src/index.ts"]
Image size: ~80MB. Boot time: <100ms.
Bun → Node bundle
If your platform requires Node:
bun build src/index.ts --outdir dist --target node
The output is a single .js runnable on Node. Use the smaller deps (postgres not pg) and you stay close to a native Bun build.
Edge
Hono runs unchanged on Cloudflare Workers, Deno, Bun, Node. Same code, four deployment targets.
Performance numbers I see in production
A Hono+Bun service hitting Postgres typically:
- 8,000–15,000 req/sec per CPU core for typed CRUD endpoints.
- p99 latency 15–30ms end to end with one DB query.
- ~80MB RSS under load.
Compare that to NestJS on Node: ~3,000 req/sec, ~250MB RSS. The simplicity tax of a “framework” is real.
When I’d reach elsewhere
- NestJS — large org with strong DI culture, full Spring-style ergonomics. NestJS is fine; the conceptual surface is bigger.
- Express — legacy or tutorial code. New code, no.
- Fastify — solid choice if you specifically need its plugin ecosystem; otherwise Hono is faster and smaller.
- Encore.ts — if you want a more opinionated framework with infra-aware DI.
What I’d add as you grow
- Drizzle migrations —
drizzle-kit generate+ amigrations/folder, applied on deploy. - Pino instead of
hono/loggerfor structured logs at scale. - OpenTelemetry — see OpenTelemetry End-to-End .
- BullMQ for background jobs, on Redis.
- Auth.js or Clerk for auth.
Read this next
- Bun vs Node.js in 2026
- FastAPI + Pydantic v2 + SQLAlchemy 2.0 — same problem, Python.
- Production HTTP Service in Rust — same problem, Rust.
If you want a starter template wiring all of this — Hono, Bun, Drizzle, OpenAPI, Docker, OTel — it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .