Cover image for Building Type-Safe APIs with Node.js, Hono, and Drizzle ORM (Full Walkthrough)

At a glance

Reading time

~200 words/min

Published

2 days ago

May 4, 2026

Views

19

All-time total

Building Type-Safe APIs with Node.js, Hono, and Drizzle ORM (Full Walkthrough)

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 http module. 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-validator  failing 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.

Newsletter

Want more posts like this?

Get practical software notes and tutorials delivered when something new is published.

No spam. Unsubscribe anytime.

Share

Related posts

Next.js 15 Server Actions vs API Routes: When to Use Each (2026 Decision Guide)

A practical decision tree for Next.js 15, when Server Actions beat API Routes, when they don't, and how to migrate cleanly. With code, security, and performance notes.

6 days ago

Vue 3.5 Vapor Mode: Performance Wins, Migration Notes, and a Real Benchmark

A complete look at Vue 3.5 Vapor Mode, what it actually changes, how to enable it per file or project-wide, what works, what doesn't, and real benchmark numbers.

9 hours ago

TypeScript Project Setup in 2026: Bun, Biome, and Vitest (A Complete Stack)

Set up a modern TypeScript project end-to-end with Bun, Biome, and Vitest. Bootstrapping, tsconfig flags, lint/format, tests, CI all in one walkthrough.

1 day ago