Jahrelang bedeutete die Wahl eines JavaScript-Web-Frameworks einen Kompromiss: Express war universell, aber langsam und an Node gebunden, Fastify war schnell, aber nur für Node, Next.js war vollständig, aber schwer. Mit dem Aufkommen von Edge-Runtimes - Cloudflare Workers, Deno Deploy, Bun, Vercel Edge - zeigten diese Frameworks ihre Grenzen: inkompatible Abhängigkeiten, riesige Bundles, an req/res gebundene APIs.
Hono (japanisch für "Flamme" 🔥) ist die moderne Antwort. Ein Framework unter 14KB, vollständig auf Web Standards (Request, Response, fetch) aufgebaut, das überall läuft, wo es eine JavaScript-Runtime gibt. Derselbe Code wird ohne Änderung auf Cloudflare Workers, Bun, Deno, Node.js, Vercel, Netlify und AWS Lambda deployt.
Warum Hono
Hono macht drei Dinge besser als jeder andere:
- Performance. Der
RegExpRouterkompiliert alle Route-Patterns zu einer einzigen Regex und vermeidet die linearen Schleifen traditioneller Router. Benchmarks zeigen über 400.000 Ops/s und reihen Hono unter die schnellsten Router des JavaScript-Ökosystems ein. - Portabilität. Web Standards bedeutet null Node-Abhängigkeiten. Dasselbe
app.fetchwird als Default in einem Cloudflare Worker exportiert, anBun.serveübergeben, in einem Deno-Server gemountet oder mit@hono/node-serveradaptiert. - TypeScript-First DX. Path-Parameter als Literal-Types, ein End-to-End-typsicherer RPC-Client, Validatoren mit Input- und Output-Inferenz. Die Autovervollständigung ist nahezu telepathisch.
Erste Schritte
Der schnellste Weg ist der offizielle Starter, der das Projekt für die gewählte Runtime erstellt.
npm create hono@latest my-api cd my-api npm install npm run dev
Der Starter fragt nach einem Template: cloudflare-workers, bun, deno, nodejs, vercel, aws-lambda, nextjs und mehr. Er wählt passende Konfigurationen und Deploy-Skripte. Für einen schnellen Test reicht auch eine einzelne Datei:
// src/index.ts import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Hello Hono!')) export default app
Auf Cloudflare Workers reicht das. Auf Bun: Bun.serve({ fetch: app.fetch, port: 3000 }). Auf Node: serve({ fetch: app.fetch }) aus @hono/node-server. Die Oberfläche ist identisch.
Routing
Routes werden mit HTTP-Verb-Methoden deklariert und unterstützen Parameter, Wildcards und Regex.
import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Home')) app.get('/posts/:id', (c) => { const id = c.req.param('id') // string, typisiert return c.json({ id }) }) app.get('/posts/:id/comments/:commentId', (c) => { const { id, commentId } = c.req.param() return c.json({ id, commentId }) }) app.get('/files/*', (c) => c.text('Wildcard')) app.post('/posts', async (c) => { const body = await c.req.json() return c.json({ created: body }, 201) })
Parameter werden als Literal-Types abgeleitet: TypeScript weiß, dass c.req.param('id') nur dann ein string zurückgibt, wenn :id im Pattern deklariert wurde. Ein Tippfehler ist ein Compile-Time-Error.
Routes gruppieren
Mit app.route() lassen sich Sub-Anwendungen als Module komponieren, jede mit eigenem Präfix.
// routes/posts.ts import { Hono } from 'hono' const posts = new Hono() posts.get('/', (c) => c.json({ posts: [] })) posts.get('/:id', (c) => c.json({ id: c.req.param('id') })) export default posts // src/index.ts import { Hono } from 'hono' import posts from './routes/posts' const app = new Hono() app.route('/posts', posts)
Verschachtelte Routes erben den Base-Path und den Typ der Root-App, sodass der RPC-Client die gesamte Struktur sieht.
Das Context-Objekt
Jeder Handler erhält ein c - den Context für die aktuelle Anfrage. Es ist die einzige API, die du lernen musst, um Eingaben zu lesen und Antworten zu produzieren.
app.post('/echo', async (c) => { // Lesen const userAgent = c.req.header('User-Agent') const page = c.req.query('page') const body = await c.req.json() const env = c.env // Bindings (KV, D1, Secrets auf Cloudflare) // Variablen zwischen Middleware und Handler geteilt c.set('requestId', crypto.randomUUID()) const id = c.get('requestId') // Antwort c.header('X-Request-Id', id) c.status(200) return c.json({ userAgent, page, body, id }) })
Die wichtigsten Response-Methoden sind c.text, c.json, c.html, c.body (raw) und c.redirect. Alle akzeptieren einen Statuscode als zweites Argument.
Middleware: das Onion-Modell
Middleware sind (c, next) => ...-Funktionen, die Code vor und nach dem Handler ausführen können. Komponiert ergeben sie das klassische Onion-Modell: die zuerst registrierte Middleware startet als erste und endet als letzte.
import { Hono } from 'hono' import { logger } from 'hono/logger' import { cors } from 'hono/cors' import { secureHeaders } from 'hono/secure-headers' const app = new Hono() app.use('*', logger()) app.use('*', secureHeaders()) app.use('/api/*', cors({ origin: 'https://spinny.dev' })) app.use('*', async (c, next) => { const start = performance.now() await next() c.header('X-Response-Time', `${performance.now() - start}ms`) })
await next() übergibt die Kontrolle an die nächste Middleware. Alles, was danach kommt, läuft in der Response-Phase, nachdem der Handler ein Ergebnis produziert hat. Das Zurückgeben einer Response vor next() bricht die Kette ab.
Eingebaute Middleware
Hono liefert eine reichhaltige Auswahl produktionsreifer Middleware aus, importierbar aus hono/...:
| Middleware | Zweck |
|---|---|
logger | Strukturierte Logs zu Methode, Pfad, Status, Dauer |
cors | CORS konfigurierbar nach Origin, Methoden, Headern |
csrf | Origin-basierter CSRF-Schutz |
secureHeaders | Setzt CSP, HSTS, X-Frame-Options |
bearerAuth / basicAuth | Bearer/Basic-Auth out of the box |
jwt | JWT-Verify/Sign mit jose |
etag | Generiert ETag und behandelt 304 |
cache | Caching über die Web Cache API |
compress | gzip/deflate auf der Response |
bodyLimit | Lehnt Bodies über einer Grenze ab |
timing | Server-Timing-Header für Profiling |
Typsichere Custom-Middleware
Um den Context mit typisierten Variablen zu erweitern, nutze createMiddleware:
import { createMiddleware } from 'hono/factory' type AuthVars = { userId: string; role: 'user' | 'admin' } export const requireAuth = createMiddleware<{ Variables: AuthVars }>( async (c, next) => { const token = c.req.header('Authorization')?.replace('Bearer ', '') if (!token) return c.json({ error: 'Unauthorized' }, 401) const payload = await verifyJwt(token) c.set('userId', payload.sub) c.set('role', payload.role) await next() } ) // Verwendung app.get('/me', requireAuth, (c) => { const userId = c.var.userId // string, typisiert return c.json({ userId }) })
Nach der Middleware ist c.var.userId ohne Cast typisiert. Das pflanzt sich durch die gesamte Kette fort.
Validierung mit Zod
@hono/zod-validator bindet Zod in den Request-Zyklus ein. Du definierst ein Schema, wendest es auf die Route an und erhältst bereits validierte, typisierte Eingaben.
import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' const createPost = z.object({ title: z.string().min(1).max(200), body: z.string().min(1), tags: z.array(z.string()).default([]), }) app.post( '/posts', zValidator('json', createPost), (c) => { const data = c.req.valid('json') // typisiert nach Schema return c.json({ ok: true, post: data }, 201) } )
Schlägt die Validierung fehl, antwortet Hono mit 400 und dem Zod-Fehler, ohne den Handler aufzurufen. Du kannst auch query, param, header, cookie und form validieren.
RPC: End-to-End-typsicherer Client
Das Feature, das Hono wirklich abhebt, ist der RPC-Modus. Du exportierst den Typ deiner App vom Server, der hc-Client importiert ihn und du erhältst vollständige Autovervollständigung - inklusive Pfade, Query, Body, Header und Response - ohne Codegen oder OpenAPI.
// server.ts import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' const app = new Hono() .get('/posts/:id', (c) => c.json({ id: c.req.param('id'), title: 'Hello' }) ) .post( '/posts', zValidator('json', z.object({ title: z.string(), body: z.string() })), (c) => c.json({ ok: true }, 201) ) export type AppType = typeof app export default app
// client.ts import { hc } from 'hono/client' import type { AppType } from './server' const client = hc<AppType>('https://api.spinny.dev') const res = await client.posts[':id'].$get({ param: { id: '42' } }) if (res.ok) { const data = await res.json() // { id: string, title: string } console.log(data.title) } const created = await client.posts.$post({ json: { title: 'Hallo', body: 'Hono ist eine Flamme' }, // validiert })
Benenne eine Route auf dem Server um und das TypeScript des Clients bricht sofort in CI. Derselbe Vorteil wie tRPC, aber über Standard-HTTP, ohne spezifische Middleware und mit einem winzigen Bundle.
Statuscode-Diskriminierung
Wenn du verschiedene Status zurückgibst, diskriminiert der Client sie automatisch.
.get('/posts/:id', (c) => { const post = findPost(c.req.param('id')) if (!post) return c.json({ error: 'not found' }, 404) return c.json({ post }, 200) })
const res = await client.posts[':id'].$get({ param: { id } }) if (res.status === 404) { const { error } = await res.json() // { error: string } } if (res.status === 200) { const { post } = await res.json() // { post: Post } }
Router und Performance
Hono bietet fünf Router mit verschiedenen Trade-offs. Default ist der SmartRouter, der beim Start misst, welcher Router deine Routes am besten bedient, und sich darauf festlegt.
| Router | Stärken | Wann einsetzen |
|---|---|---|
RegExpRouter | Höchste Geschwindigkeit, kompilierte Regex | Default für die meisten APIs |
TrieRouter | Unterstützt jedes Pattern | Komplexe Patterns, die RegExp nicht abdeckt |
SmartRouter | Wählt automatisch den besten | Empfohlener Default |
LinearRouter | Ultraschnelle Registrierung | One-Shot-Worker, kritische Cold Starts |
PatternRouter | Kleinstes Bundle (<15KB) | Extreme Größenbeschränkungen |
Für stateless Worker mit häufigen Cold Starts vermeidet LinearRouter die initialen Kompilierungskosten und ist 33-mal schneller als find-my-way, wenn Registrierung plus Matching gemessen werden.
import { Hono } from 'hono' import { LinearRouter } from 'hono/router/linear-router' const app = new Hono({ router: new LinearRouter() })
Multi-Runtime-Deployment
Dieselbe export default app wechselt das Ziel, indem nur die Entry-Datei getauscht wird.
Cloudflare Workers
// src/index.ts import { Hono } from 'hono' type Bindings = { MY_KV: KVNamespace; DB: D1Database } const app = new Hono<{ Bindings: Bindings }>() app.get('/cache/:key', async (c) => { const value = await c.env.MY_KV.get(c.req.param('key')) return c.json({ value }) }) export default app
Deploy: npx wrangler deploy. KV/D1/R2/Queues-Bindings sind nativ.
Bun
import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Bun + Hono')) Bun.serve({ fetch: app.fetch, port: 3000 })
Node.js
import { serve } from '@hono/node-server' import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Node + Hono')) serve({ fetch: app.fetch, port: 3000 })
Deno
import { Hono } from 'jsr:@hono/hono' const app = new Hono() app.get('/', (c) => c.text('Deno + Hono')) Deno.serve(app.fetch)
Vercel
// api/[[...route]].ts import { Hono } from 'hono' import { handle } from 'hono/vercel' const app = new Hono().basePath('/api') app.get('/hello', (c) => c.json({ msg: 'Hello from Vercel' })) export const GET = handle(app) export const POST = handle(app)
Gleicher Business-Code, gleiches Routing, gleiche Middleware. Nur der Adapter ändert sich.
Praxisbeispiel: REST-API mit Auth und DB
Alles zusammengeführt. Eine Blog-API auf Cloudflare Workers + D1, mit JWT-Auth, Validierung und RPC.
import { Hono } from 'hono' import { jwt } from 'hono/jwt' import { logger } from 'hono/logger' import { cors } from 'hono/cors' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' type Bindings = { DB: D1Database; JWT_SECRET: string } type Variables = { jwtPayload: { sub: string } } const app = new Hono<{ Bindings: Bindings; Variables: Variables }>() app.use('*', logger()) app.use('/api/*', cors({ origin: 'https://spinny.dev', credentials: true })) const auth = (c: any, next: any) => jwt({ secret: c.env.JWT_SECRET })(c, next) const api = app.basePath('/api') api.get('/posts', async (c) => { const { results } = await c.env.DB .prepare('SELECT id, title, created_at FROM posts ORDER BY created_at DESC LIMIT 50') .all() return c.json({ posts: results }) }) api.post( '/posts', auth, zValidator('json', z.object({ title: z.string().min(1).max(200), body: z.string().min(1), })), async (c) => { const { title, body } = c.req.valid('json') const userId = c.var.jwtPayload.sub const result = await c.env.DB .prepare('INSERT INTO posts (title, body, author_id) VALUES (?, ?, ?) RETURNING id') .bind(title, body, userId) .first<{ id: number }>() return c.json({ id: result?.id }, 201) } ) api.onError((err, c) => { console.error(err) return c.json({ error: 'Internal error' }, 500) }) export type AppType = typeof api export default app
Ein React-Client konsumiert sie mit voller Typsicherheit:
import { hc } from 'hono/client' import type { AppType } from '../api/src/index' const api = hc<AppType>(import.meta.env.VITE_API_URL) const res = await api.posts.$post({ json: { title: 'Hono in 2026', body: '...' }, }, { headers: { Authorization: `Bearer ${token}` }, })
Testing
app.request() erlaubt das Testen von Routes, ohne einen HTTP-Server zu starten. Es ist derselbe Pfad, den du in Produktion ausführen würdest, im Speicher ausgeführt.
import { describe, it, expect } from 'vitest' import app from '../src/index' describe('GET /api/posts', () => { it('liefert die Liste der Posts', async () => { const res = await app.request('/api/posts') expect(res.status).toBe(200) const body = await res.json() expect(body.posts).toBeInstanceOf(Array) }) })
Auf Cloudflare Workers führt @cloudflare/vitest-pool-workers dieselben Tests in einem echten Worker mit Mock-Bindings aus - maximale Realität, kein Deploy.
Best Practices
1. Route-Definitionen verketten
const app = new Hono() .get('/posts', handler1) .post('/posts', handler2) .get('/posts/:id', handler3)
Chaining hält den Typ von app mit jeder Route aktuell, was für den RPC-Client essenziell ist. Separate app.get(...)-Aufrufe in eigenen Zeilen brechen die Inferenz.
2. Den Typ exportieren, nicht die Implementierung
Der Client muss AppType importieren, nicht die App. import type stellt sicher, dass der Frontend-Build keinen Backend-Code enthält.
3. Ein Router pro Domäne
Eine Sub-App für posts, eine für users, eine für webhooks. Komponiere sie mit app.route() und jede besitzt ihre eigene Middleware. Die Struktur skaliert ohne Mega-Dateien.
4. Validierung am Rand, immer
Jede externe Eingabe (Body, Query, Header) muss durch zValidator. Vertraue Daten flussabwärts nicht: selbst ein TypeScript-Cast ohne Runtime-Validierung ist ein Bug, der nur darauf wartet zuzuschlagen.
5. Auf Bindings setzen, nicht auf globale Clients
Auf Cloudflare greife auf KV/D1/R2 über c.env zu. Keine globalen Singletons, keine Verbindungen, die zwischen Workern persistieren. Das stateless Modell ist ein Feature, keine Einschränkung.
6. Vor dem Optimieren des Routers messen
Der Default-SmartRouter reicht für 95 % der Fälle. Wechsle den Router erst nach dem Profiling und einem realen Bottleneck.
Fazit
Hono ist 2026 zum De-facto-Standard geworden, um edge-fähige APIs in TypeScript zu bauen. Die Kombination aus Web Standards, Performance, Typsicherheit und Portabilität löst genau die Probleme, die traditionelle Frameworks zurückhielten: Runtime-Lock-in, schwere Bundles, fragiles Typsystem zwischen Client und Server.
Du nutzt es für Microservices auf Cloudflare Workers, um Express in einem Node-Monolithen zu ersetzen, für eine Function auf Vercel oder eine API auf Bun. Dasselbe Wissen lässt sich überall einsetzen - und das macht es zu einer soliden Investition für die kommenden Jahre.
Erste-Schritte-Checkliste:
npm create hono@latestund Runtime-Template wählen- Routes mit Chaining definieren (
.get(...).post(...))logger,cors,secureHeadersals globale Middleware hinzufügen- Jeden Input mit
@hono/zod-validatorvalidierenAppTypeexportieren und die API mit dem typsicherenhc-Client konsumieren- Tests mit
app.request()schreiben - kein HTTP-Server nötig- Mit
wrangler deploy(CF),vercel deployoder dem Runtime-Bundler deployen