Express has carried Node.js for over a decade. It still works, and there's nothing wrong with continuing to ship it. But if you're starting something new in 2026, the combination of Hono for routing and Drizzle for SQL gives you a stack that is faster, type-safe end-to-end, and runs unchanged on Node, Bun, Deno, Cloudflare Workers, or Vercel Edge — without the runtime gymnastics that used to require.
This post builds a small but complete API: schema → migrations → routes → typed responses. Every layer carries types automatically, so when you change a column the route handler refuses to compile until you update it. That's the dream of "TypeScript everywhere" actually delivered.
Why Hono
- Tiny : about 14KB minified, no dependency tree.
- Fast : built on the Web Fetch API, not Node's
httpmodule. Routes resolve via a trie that beats Express's regex-based router by ~3× in benchmarks. - Universal : same code on Node, Bun, Deno, Cloudflare Workers, AWS Lambda, Vercel Edge.
- First-class TypeScript : route params, JSON bodies, and middleware are all typed without ceremony.
Why Drizzle
- Schema as code : your TypeScript file is the schema. No DSL, no decorators.
- Real SQL builder : query syntax mirrors SQL, no clever ORM abstractions to fight.
- Typed inference : query results are inferred from the schema. No
any, no manual interfaces. - Migrations : generated from schema diffs, applied by a CLI.
Project setup
mkdir todo-api && cd todo-api
npm init -y
npm install hono @hono/node-server drizzle-orm pg
npm install -D drizzle-kit @types/pg @types/node typescript tsx
npx tsc --init --target esnext --module nodenext --moduleResolution nodenext --strict
Add convenience scripts:
{
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate"
}
}
Defining the schema
// src/db/schema.ts
import { pgTable, serial, text, boolean, timestamp } from 'drizzle-orm/pg-core';
export const todos = pgTable('todos', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
completed: boolean('completed').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export type Todo = typeof todos.$inferSelect;
export type NewTodo = typeof todos.$inferInsert;
The two type exports at the bottom give you, for free, the row type returned by selects and the type required for inserts. No manual interfaces, no codegen step.
Drizzle config + migrations
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: { url: process.env.DATABASE_URL! },
});
npm run db:generate # creates SQL migration from schema diff
npm run db:migrate # applies it
A typed Hono server
// src/server.ts
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { logger } from 'hono/logger';
import { eq } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { todos, type NewTodo } from './db/schema';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool);
const app = new Hono();
app.use('*', logger());
app.get('/todos', async (c) => {
const all = await db.select().from(todos);
return c.json(all);
});
app.get('/todos/:id', async (c) => {
const id = Number(c.req.param('id'));
const [row] = await db.select().from(todos).where(eq(todos.id, id));
if (!row) return c.json({ error: 'Not found' }, 404);
return c.json(row);
});
app.post('/todos', async (c) => {
const body = (await c.req.json()) as Pick<NewTodo, 'title'>;
if (!body.title?.trim()) return c.json({ error: 'title required' }, 400);
const [row] = await db.insert(todos).values({ title: body.title.trim() }).returning();
return c.json(row, 201);
});
app.patch('/todos/:id/toggle', async (c) => {
const id = Number(c.req.param('id'));
const [current] = await db.select().from(todos).where(eq(todos.id, id));
if (!current) return c.json({ error: 'Not found' }, 404);
const [row] = await db
.update(todos)
.set({ completed: !current.completed })
.where(eq(todos.id, id))
.returning();
return c.json(row);
});
app.delete('/todos/:id', async (c) => {
await db.delete(todos).where(eq(todos.id, Number(c.req.param('id'))));
return c.body(null, 204);
});
const port = Number(process.env.PORT ?? 3000);
serve({ fetch: app.fetch, port }, ({ port }) => {
console.log(`Hono on http://localhost:${port}`);
});
Notice what's not in this file: no manual interfaces describing the response shape, no any, no DTO classes. Hover over row in your editor and TypeScript will tell you exactly what columns are present.
End-to-end type safety to the client
Hono's RPC mode goes one step further. You can export the typed app and import it as a client in your frontend repo. The client gets every route, every param, and every response shape inferred from the server file.
// server: export the AppType
export type AppType = typeof app;
// client (separate package, types only):
import { hc } from 'hono/client';
import type { AppType } from '../server/src/server';
const client = hc<AppType>('http://localhost:3000');
const res = await client.todos.$get();
const todos = await res.json(); // typed: { id, title, completed, createdAt }[]
That's full-stack TypeScript without GraphQL, without OpenAPI, without a separate codegen step. The contract is the type.
Edge deployment, same code
The same app instance runs on Cloudflare Workers without changes, swap the entry point:
// worker.ts
import app from './server';
export default app;
For Drizzle, switch to a Workers-compatible Postgres driver such as @neondatabase/serverless or use drizzle-orm/d1 for Cloudflare D1.
Production checklist
- Add input validation with
@hono/zod-validatorfailing requests should never reach the database call. - Wrap mutations in transactions when more than one statement runs.
- Add a request-ID middleware and structured logging, Hono's logger is fine for dev, not for prod.
- Set up health checks that hit the DB (not just the process) so an orchestrator can recycle a stuck pod.
- Pin the Drizzle and Hono versions; both move quickly and minor versions occasionally change types.
The point isn't "Express is bad." It's that the new combination : Web-standard router, schema-as-types ORM, runtime portability gives you a noticeably nicer experience for the same effort.
When to choose this stack
Pick Hono + Drizzle when you want a small, fast API with strong types from database to client, and especially when you might want to deploy to the edge later. Stick with Express + Prisma if your team is already shipping it and the new gains don't justify the change cost. Both are good choices in 2026 but the new stack now has the better defaults.