Durante años, elegir un framework web en JavaScript significaba aceptar un compromiso: Express era universal pero lento y atado a Node, Fastify era rápido pero solo para Node, Next.js era completo pero pesado. Cuando llegaron los runtimes edge - Cloudflare Workers, Deno Deploy, Bun, Vercel Edge - esos frameworks mostraron sus límites: dependencias incompatibles, bundles enormes, APIs ligadas a req/res de Node.
Hono (japonés para "llama" 🔥) es la respuesta moderna. Un framework de menos de 14KB, construido enteramente sobre Web Standards (Request, Response, fetch), que corre dondequiera que exista un runtime JavaScript. El mismo código se despliega en Cloudflare Workers, Bun, Deno, Node.js, Vercel, Netlify y AWS Lambda - sin cambios.
Por qué Hono
Hono hace tres cosas mejor que cualquier otro:
- Performance. El
RegExpRoutercompila todos los patrones de ruta en una única regex, evitando los bucles lineales de los routers tradicionales. Los benchmarks superan las 400.000 ops/s, situando a Hono entre los routers más rápidos del ecosistema JavaScript. - Portabilidad. Web Standards significa cero dependencias de Node. El mismo
app.fetchse exporta como default en un Cloudflare Worker, se pasa aBun.serve, se monta en un servidor Deno o se adapta con@hono/node-server. - DX TypeScript-first. Path parameters tipados como literales, cliente RPC type-safe end-to-end, validadores que infieren tipos de entrada y salida. El autocompletado es casi telepático.
Empezar
La forma más rápida es el starter oficial, que andamia el proyecto para el runtime elegido.
npm create hono@latest my-api cd my-api npm install npm run dev
El starter pregunta qué template usar: cloudflare-workers, bun, deno, nodejs, vercel, aws-lambda, nextjs y más. Elige las configs y scripts de despliegue adecuados. Para probar al vuelo, también puedes empezar desde un solo archivo:
// src/index.ts import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Hello Hono!')) export default app
En Cloudflare Workers esto basta. En Bun: Bun.serve({ fetch: app.fetch, port: 3000 }). En Node: serve({ fetch: app.fetch }) desde @hono/node-server. La superficie es idéntica.
Routing
Las rutas se declaran con métodos de verbos HTTP y soportan parámetros, wildcards y 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, tipado 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) })
Los parámetros se infieren como tipos literales: TypeScript sabe que c.req.param('id') retorna string solo si declaraste :id en el patrón. Equivocarse en el nombre es un error de compilación.
Agrupar rutas
app.route() permite componer subaplicaciones como módulos, cada una con su prefijo.
// 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)
Las rutas anidadas heredan el base path y el tipo de la app raíz, por lo que el cliente RPC ve toda la estructura.
El objeto Context
Cada handler recibe un c - el Context para la solicitud actual. Es la única API que necesitas aprender para leer entradas y producir salidas.
app.post('/echo', async (c) => { // Lectura 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 en Cloudflare) // Variables compartidas entre middleware y handler c.set('requestId', crypto.randomUUID()) const id = c.get('requestId') // Respuesta c.header('X-Request-Id', id) c.status(200) return c.json({ userAgent, page, body, id }) })
Los métodos de respuesta principales son c.text, c.json, c.html, c.body (raw) y c.redirect. Todos aceptan un código de estado como segundo argumento.
Middleware: el modelo en cebolla
Los middleware son funciones (c, next) => ... que pueden ejecutar código antes y después del handler. Compuestos forman el clásico modelo en cebolla: el primer middleware registrado es el primero en empezar y el último en terminar.
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 el control al siguiente middleware. Todo lo posterior corre en la fase de respuesta, después de que el handler haya producido un resultado. Devolver una Response antes de next() corta la cadena.
Middleware integrados
Hono incluye un set rico de middleware listos para producción, importables desde hono/...:
| Middleware | Para qué sirve |
|---|---|
logger | Logs estructurados de método, path, status, duración |
cors | CORS configurable por origin, métodos, headers |
csrf | Protección CSRF basada en origin |
secureHeaders | Establece CSP, HSTS, X-Frame-Options |
bearerAuth / basicAuth | Auth Bearer/Basic out of the box |
jwt | Verifica y firma JWT con jose |
etag | Genera ETag y maneja 304 |
cache | Cache vía Web Cache API |
compress | gzip/deflate sobre la respuesta |
bodyLimit | Rechaza bodies por encima de un umbral |
timing | Header Server-Timing para profiling |
Middleware custom type-safe
Para extender el Context con variables tipadas, 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, tipado return c.json({ userId }) })
Aguas abajo del middleware, c.var.userId está tipado sin ningún cast. Esto se propaga por toda la cadena.
Validación con Zod
@hono/zod-validator integra Zod en el ciclo de la solicitud. Defines un schema, lo aplicas a la ruta y obtienes entradas ya validadas y tipadas.
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') // tipado según el schema return c.json({ ok: true, post: data }, 201) } )
Si el body no valida, Hono responde 400 con el error de Zod antes de llamar al handler. También puedes validar query, param, header, cookie y form.
RPC: cliente type-safe end-to-end
La feature que realmente diferencia a Hono es el modo RPC. Exportas el tipo de tu app desde el servidor, el cliente hc lo importa y obtienes autocompletado completo - paths, query, body, headers y respuesta - sin codegen ni 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: 'Hola', body: 'Hono es una llama' }, // validado })
Renombras una ruta en el servidor y el TypeScript del cliente rompe inmediatamente en CI. Mismo beneficio que tRPC, pero sobre HTTP estándar, sin middleware específico y con un bundle minúsculo.
Status codes discriminantes
Si retornas estados distintos, el cliente los discrimina automáticamente.
.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 y performance
Hono ofrece cinco routers con distintos trade-offs. El default es el SmartRouter, que al arranque mide cuál sirve mejor tus rutas y se fija en él.
| Router | Fortalezas | Cuándo usar |
|---|---|---|
RegExpRouter | Velocidad máxima, regex compilada | Default para la mayoría de APIs |
TrieRouter | Soporta todos los patrones | Patrones complejos no cubiertos por RegExp |
SmartRouter | Elige el mejor automáticamente | Default recomendado |
LinearRouter | Registro ultra rápido | Workers one-shot, cold start crítico |
PatternRouter | Bundle mínimo (<15KB) | Restricciones de tamaño extremas |
Para workers stateless con cold starts frecuentes, LinearRouter evita el coste de compilación inicial y es 33x más rápido que find-my-way cuando se mide registro más matching.
import { Hono } from 'hono' import { LinearRouter } from 'hono/router/linear-router' const app = new Hono({ router: new LinearRouter() })
Despliegue multi-runtime
El mismo export default app cambia de destino con solo intercambiar el archivo de entrada.
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
Despliegue: npx wrangler deploy. Bindings KV/D1/R2/Queues son nativos.
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)
Mismo código de negocio, mismo routing, mismo middleware. Solo cambia el adaptador.
Ejemplo práctico: API REST con auth y BD
Juntando las piezas. Una API de blog en Cloudflare Workers + D1, con auth JWT, validación y 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
Un cliente React la consume con type safety total:
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 en 2026', body: '...' }, }, { headers: { Authorization: `Bearer ${token}` }, })
Testing
app.request() permite testear rutas sin levantar un servidor HTTP. Es el mismo path que correrías en producción, ejecutado en memoria.
import { describe, it, expect } from 'vitest' import app from '../src/index' describe('GET /api/posts', () => { it('retorna la lista de posts', async () => { const res = await app.request('/api/posts') expect(res.status).toBe(200) const body = await res.json() expect(body.posts).toBeInstanceOf(Array) }) })
En Cloudflare Workers, @cloudflare/vitest-pool-workers corre los mismos tests dentro de un Worker real con bindings mock - máximo realismo, cero deploy.
Mejores prácticas
1. Encadenar definiciones de ruta
const app = new Hono() .get('/posts', handler1) .post('/posts', handler2) .get('/posts/:id', handler3)
El chaining mantiene el tipo de app actualizado con cada ruta, esencial para el cliente RPC. Definirlas separadas con app.get(...) en líneas distintas rompe la inferencia.
2. Exporta el tipo, no la implementación
El cliente debe importar AppType, no la app. import type garantiza que el build del frontend no incluya código de backend.
3. Un router por dominio
Una sub-app para posts, una para users, una para webhooks. Compón con app.route() y cada una posee su middleware. La estructura escala sin mega-archivos.
4. Validación en el borde, siempre
Toda entrada externa (body, query, header) debe pasar por zValidator. No confíes en datos aguas abajo: incluso un cast TypeScript sin validación runtime es un bug en espera.
5. Apóyate en bindings, no en clientes globales
En Cloudflare, accede a KV/D1/R2 vía c.env. Sin singletons globales, sin conexiones persistiendo entre Workers. El modelo stateless es una feature, no una limitación.
6. Mide antes de optimizar el router
El SmartRouter por defecto sirve para el 95% de los casos. Cambia de router solo después de haber profilado y visto un cuello de botella real.
Conclusión
Hono se ha vuelto en 2026 el estándar de facto para construir APIs listas para el edge en TypeScript. La combinación de Web Standards, performance, type safety y portabilidad resuelve precisamente los problemas que frenaban a los frameworks tradicionales: lock-in al runtime, bundles pesados, type system frágil entre cliente y servidor.
Lo usarás para microservicios en Cloudflare Workers, para reemplazar Express en un monolito Node, para una function en Vercel o para una API en Bun. El mismo conocimiento se transfiere a todas partes - y eso es lo que lo hace una inversión sólida para los próximos años.
Checklist para empezar:
npm create hono@latesty elige tu template de runtime- Define rutas con chaining (
.get(...).post(...))- Añade
logger,cors,secureHeaderscomo middleware globales- Valida toda entrada con
@hono/zod-validator- Exporta
AppTypey consume la API con el clientehctype-safe- Escribe tests con
app.request()- sin servidor HTTP- Despliega con
wrangler deploy(CF),vercel deployo el bundler de tu runtime