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.email is string.
  • 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 migrationsdrizzle-kit generate + a migrations/ folder, applied on deploy.
  • Pino instead of hono/logger for 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

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 .