Per anni la scelta di un framework web in JavaScript significava accettare un compromesso: Express era universale ma lento e legato a Node.js, Fastify era veloce ma sempre Node-only, Next.js era completo ma pesante. Quando sono arrivati i runtime edge - Cloudflare Workers, Deno Deploy, Bun, Vercel Edge - quei framework hanno mostrato i loro limiti: dipendenze incompatibili, bundle giganti, API legate a req/res di Node.
Hono (giapponese per "fiamma" 🔥) e la risposta moderna a quel problema. E un framework di meno di 14KB, costruito interamente sui Web Standards (Request, Response, fetch), che gira ovunque ci sia un runtime JavaScript. Lo stesso codice viene deployato su Cloudflare Workers, Bun, Deno, Node.js, Vercel, Netlify e AWS Lambda senza modifiche.
Perche Hono
Hono fa tre cose meglio di chiunque altro:
- Performance. Il
RegExpRoutercompila tutti i pattern di route in una singola regex, evitando i loop lineari dei router tradizionali. I benchmark mostrano oltre 400.000 op/s, rendendo Hono uno dei router piu veloci nell'ecosistema JavaScript. - Portabilita. Web Standards significa zero dipendenze da Node. Lo stesso
app.fetchviene esportato come default in un Cloudflare Worker, passato aBun.serve, montato in un Deno server o adattato a@hono/node-server. - DX TypeScript-first. Path parameters tipati come literal, RPC client end-to-end type-safe, validatori che inferiscono i tipi di input e output. L'autocompletion e quasi telepatica.
Iniziare
Il modo piu rapido e lo starter ufficiale, che genera il progetto e il template per il runtime scelto.
npm create hono@latest my-api cd my-api npm install npm run dev
Lo starter chiede quale template usare: cloudflare-workers, bun, deno, nodejs, vercel, aws-lambda, nextjs, e altri. Sceglie configurazioni e script di deploy adeguati. Per provare al volo, puoi anche partire da un singolo file:
// src/index.ts import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Hello Hono!')) export default app
Su Cloudflare Workers questo basta. Su Bun: Bun.serve({ fetch: app.fetch, port: 3000 }). Su Node: serve({ fetch: app.fetch }) da @hono/node-server. La superficie e identica.
Routing
Le route si dichiarano con i metodi HTTP e supportano parametri, wildcard e 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, tipato 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) })
I parametri sono inferiti come literal type: TypeScript sa che c.req.param('id') restituisce string solo se hai dichiarato :id nel pattern. Sbagliarne il nome e un errore di compilazione.
Raggruppare le route
app.route() permette di comporre sotto-applicazioni come moduli, ognuna con il suo prefisso.
// 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)
Le route nidificate ereditano il base path e il tipo dell'app radice, quindi il client RPC vede tutta la struttura.
L'oggetto Context
Ogni handler riceve un c - il Context per la richiesta corrente. E l'unica API che devi imparare per leggere input e produrre output.
app.post('/echo', async (c) => { // Lettura 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 su Cloudflare) // Variabili condivise tra middleware e handler c.set('requestId', crypto.randomUUID()) const id = c.get('requestId') // Risposta c.header('X-Request-Id', id) c.status(200) return c.json({ userAgent, page, body, id }) })
I metodi di risposta principali sono c.text, c.json, c.html, c.body (raw) e c.redirect. Tutti accettano uno status code come secondo argomento.
Middleware: il modello a cipolla
I middleware sono funzioni (c, next) => ... che possono eseguire codice prima e dopo l'handler. Componendoli, ottieni il classico onion model: il primo middleware registrato e il primo a iniziare e l'ultimo a finire.
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() cede il controllo al middleware successivo. Tutto cio che segue gira in fase di response, dopo che l'handler ha prodotto un risultato. Restituire una Response prima di next() interrompe la catena.
Middleware integrati
Hono spedisce con un set ricco di middleware production-ready, importabili da hono/...:
| Middleware | Cosa fa |
|---|---|
logger | Log strutturato di metodo, path, status, durata |
cors | CORS configurabile per origin, metodi, headers |
csrf | Protezione CSRF basata su origin |
secureHeaders | Imposta CSP, HSTS, X-Frame-Options |
bearerAuth / basicAuth | Auth Bearer/Basic out of the box |
jwt | Verifica e firma JWT con jose |
etag | Genera ETag e gestisce 304 |
cache | Cache su Web Cache API |
compress | gzip/deflate sulla response |
bodyLimit | Rifiuta body oltre una soglia |
timing | Server-Timing header per profiling |
Middleware custom type-safe
Per estendere il Context con variabili tipate, usa 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() } ) // Uso app.get('/me', requireAuth, (c) => { const userId = c.var.userId // string, tipato return c.json({ userId }) })
A valle del middleware, c.var.userId e tipato senza alcun cast. Questo si propaga attraverso tutta la catena.
Validazione con Zod
@hono/zod-validator integra Zod nel ciclo richiesta. Definisci uno schema, lo applichi alla route e ottieni input gia validati e tipati.
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') // tipato secondo lo schema return c.json({ ok: true, post: data }, 201) } )
Se il body non valida, Hono risponde 400 con l'errore Zod prima ancora di chiamare l'handler. Puoi validare anche query, param, header, cookie e form.
RPC: client type-safe end-to-end
La feature che differenzia davvero Hono dagli altri framework e l'RPC mode. Esporti il tipo della tua app dal server, il client hc lo importa e ottieni autocompletion completa - inclusi path, query, body, headers e response - senza generazione di codice o 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: 'Ciao', body: 'Hono e una fiamma' }, // validato })
Cambi un nome di route sul server e il TypeScript del client si rompe immediatamente in CI. E lo stesso vantaggio di tRPC, ma su HTTP standard, senza middleware specifici e con un bundle minuscolo.
Status code discriminanti
Se restituisci status diversi, il client li discrimina automaticamente.
.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 } }
Routers e performance
Hono offre cinque router con trade-off diversi. Il default e lo SmartRouter, che a startup misura quale router serve meglio le tue route e si fissa su quello.
| Router | Punti di forza | Quando usarlo |
|---|---|---|
RegExpRouter | Massima velocita, regex compilata | Default per la maggior parte delle API |
TrieRouter | Supporta tutti i pattern | Pattern complessi non gestiti dal RegExp |
SmartRouter | Sceglie il migliore in automatico | Default consigliato |
LinearRouter | Registrazione velocissima | Worker one-shot, cold start critici |
PatternRouter | Bundle minimo (<15KB) | Vincoli di size estremi |
Per worker stateless con cold start frequenti, LinearRouter evita il costo di compilazione iniziale ed e 33x piu veloce di find-my-way quando si misura registrazione + match.
import { Hono } from 'hono' import { LinearRouter } from 'hono/router/linear-router' const app = new Hono({ router: new LinearRouter() })
Deploy multi-runtime
Lo stesso export default app cambia destinazione cambiando solo il file di entry.
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. Bindings KV/D1/R2/Queues sono nativi.
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)
Lo stesso codice business, lo stesso routing, lo stesso middleware. Cambia solo l'adattatore.
Esempio pratico: REST API con auth e DB
Mettiamo insieme i pezzi. Una API per un blog su Cloudflare Workers + D1, con JWT auth, validazione e 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
Il client React lo consuma con type safety totale:
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}` }, })
Test
app.request() permette di testare le route senza far partire un server HTTP. E lo stesso path che useresti in produzione, eseguito in-memory.
import { describe, it, expect } from 'vitest' import app from '../src/index' describe('GET /api/posts', () => { it('restituisce la lista dei post', async () => { const res = await app.request('/api/posts') expect(res.status).toBe(200) const body = await res.json() expect(body.posts).toBeInstanceOf(Array) }) })
Su Cloudflare Workers, @cloudflare/vitest-pool-workers fa girare gli stessi test dentro un Worker reale con bindings mock - massimo realismo, zero deploy.
Best practice
1. Concatenare le definizioni di route
const app = new Hono() .get('/posts', handler1) .post('/posts', handler2) .get('/posts/:id', handler3)
Il chaining mantiene il tipo di app aggiornato a ogni route, fondamentale per il client RPC. Definirle separatamente con app.get(...) su righe distinte rompe l'inferenza.
2. Esporta il tipo, non l'implementazione
Il client deve importare AppType, non l'app. import type garantisce che la build del frontend non includa il codice del backend.
3. Mantieni un router per dominio
Una sotto-app per posts, una per users, una per webhooks. Le componi con app.route() e ognuna ha i propri middleware. La struttura scala senza mega-file.
4. Validazione al bordo, sempre
Ogni input esterno (body, query, header) deve passare per zValidator. Non fidarti dei dati a valle: anche un cast TypeScript senza validazione runtime e un bug in attesa.
5. Sfrutta i bindings, non i client globali
Su Cloudflare, accedi a KV/D1/R2 via c.env. Niente singleton globali, niente connessioni che persistono tra Worker. Il modello stateless e una feature, non una limitazione.
6. Misura prima di ottimizzare il router
Lo SmartRouter di default va bene per il 95% dei casi. Cambia router solo dopo aver profilato e visto un collo di bottiglia reale.
Conclusione
Hono e diventato nel 2026 lo standard de facto per costruire API edge-ready in TypeScript. La combinazione di Web Standards, performance, type safety e portabilita risolve esattamente i problemi che bloccavano i framework tradizionali: lock-in al runtime, bundle pesanti, type system fragile tra client e server.
Lo userai per microservizi su Cloudflare Workers, per sostituire Express in un monolito Node, per una function su Vercel o per una API in Bun. La stessa knowledge si trasferisce ovunque - ed e questo che lo rende un investimento solido per i prossimi anni.
Checklist per Iniziare:
npm create hono@lateste scegli il template del runtime- Definisci le route con il chaining (
.get(...).post(...))- Aggiungi
logger,cors,secureHeaderscome middleware globali- Valida ogni input con
@hono/zod-validator- Esporta
AppTypee consuma l'API col clienthctype-safe- Scrivi test con
app.request()senza HTTP server- Deploy con
wrangler deploy(CF),vercel deployo il bundler del tuo runtime