Hono a fondo: el framework web ultrarrápido que funciona en cualquier runtime
Qué es Hono, cómo funcionan por dentro sus routers y su middleware, y mucho código probado: rutas, validación con Zod, errores, JSX, streaming, cliente RPC tipado, pruebas y despliegue en Node, Bun, Deno, Workers y Docker.
Contenido
Durante más de una década, escribir un servidor HTTP en JavaScript significaba escribirlo para Node.js. Express era la respuesta por defecto y casi nadie se planteaba otra cosa. Hoy el panorama es otro: tenemos Bun, Deno, Cloudflare Workers, funciones en AWS Lambda y Vercel, y cada uno ejecuta JavaScript a su manera. Un servidor escrito para Node no funciona sin más en un Worker de Cloudflare, porque allí no existe http.createServer.
Hono nace precisamente de ese problema. Su creador, Yusuke Wada, lo empezó en 2021 para escribir aplicaciones en Cloudflare Workers y acabó construyendo un framework que funciona igual en todos esos entornos. La clave es que no se apoya en las APIs propias de Node, sino en los estándares web: los mismos objetos Request y Response que usas con fetch() en el navegador.
En este artículo vamos a verlo a fondo: qué es, cómo funciona por dentro (sus cinco routers, el contexto, el modelo de middleware en cebolla), cómo se descarga e instala, y después mucho código: rutas, middleware propios e integrados, validación con Zod, manejo de errores, vistas con JSX, streaming y eventos del servidor, el cliente RPC con tipos de extremo a extremo, pruebas con Vitest, una API CRUD completa y cómo desplegarlo en Node, Bun, Deno, Cloudflare Workers y en un servidor propio con Docker y Coolify.
Qué es Hono
Hono (炎, "llama" en japonés) es un framework web pequeño, rápido y basado en estándares web para crear APIs, sitios y aplicaciones en cualquier runtime de JavaScript. Su lema oficial lo resume bien: Web application framework built on Web Standards. Tiene licencia MIT y no depende de ningún paquete externo: npm install hono no descarga nada más.
Una aplicación Hono es, en el fondo, un objeto con un método fetch que recibe un Request y devuelve un Response. Eso es exactamente lo que esperan Cloudflare Workers, Bun y Deno, y con un pequeño adaptador también Node.js, AWS Lambda o Vercel. Escribes tu aplicación una vez y cambias solo la línea que la arranca.
Sus características principales
- Ultrarrápido: su router por defecto,
RegExpRouter, compila todas las rutas en una única expresión regular. En los benchmarks de Cloudflare Workers suele quedar entre los más rápidos. - Ligero: con el preset
hono/tiny, una aplicación mínima empaquetada y minificada pesa menos de 12 KB (lo he medido más abajo). Sin dependencias. - Multi-runtime: Cloudflare Workers, Fastly Compute, Deno, Bun, Vercel, Netlify, AWS Lambda, Lambda@Edge y Node.js, con el mismo código.
- Todo incluido: middleware integrados para CORS, JWT, autenticación básica y por token, ETag, compresión, cabeceras de seguridad, caché, límite de tamaño del cuerpo, tiempo máximo, CSRF, IDs de petición, restricción por IP y más.
- TypeScript de primera clase: los parámetros de ruta, las variables del contexto y los datos validados salen tipados sin escribir tipos a mano. Y con el cliente RPC, el frontend conoce los tipos de tu API sin generar código.
- JSX sin React: puedes devolver HTML con componentes JSX renderizados en el servidor, sin DOM virtual ni dependencias.
- Utilidades para todo: streaming, Server-Sent Events, WebSockets, cookies, generación de sitios estáticos (SSG), proxy, pruebas…
Quién lo usa
Según la propia web de Hono, lo usan en producción Cloudflare (en las APIs internas de D1 y Workers KV), cdnjs, Clerk, Unkey, OpenStatus y los benchmarks de Deno, entre otros. También es la base de HonoX, su meta-framework con islas, y de muchos servidores MCP y backends de agentes de IA, donde el streaming y los Workers encajan muy bien.
Descarga e instalación
Estos son los enlaces oficiales. La versión estable en el momento de escribir esto es la 4.13.11, publicada el 29 de septiembre de 2026 (corrige un fallo de seguridad en serveStatic, así que conviene estar al día).
| Recurso | Enlace |
|---|---|
| Web oficial y documentación | hono.dev · guía · referencia de la API |
| Paquete en npm | npmjs.com/package/hono |
| Código fuente | github.com/honojs/hono · versiones y notas |
| Adaptador para Node.js | @hono/node-server · GitHub |
| Middleware de terceros | github.com/honojs/middleware (zod-validator, OpenAPI, Sentry, Prometheus, etc.) |
| Deno (JSR) | jsr.io/@hono/hono |
Crear un proyecto nuevo con la plantilla oficial
La forma más rápida es create-hono, que te pregunta el runtime y te deja un proyecto listo:
# Te pregunta carpeta, plantilla (runtime) y gestor de paquetes
npm create hono@latest mi-app
# O sin preguntas: plantilla de Node.js, npm e instalar dependencias
npm create hono@latest mi-app -- --template nodejs --pm npm --install
cd mi-app
npm run dev # tsx watch src/index.ts -> http://localhost:3000Las plantillas disponibles incluyen nodejs, bun, deno, cloudflare-workers, aws-lambda, lambda-edge, vercel, netlify, fastly y x-basic (HonoX). La de Node genera exactamente esto:
{
"name": "mi-app",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@hono/node-server": "^2.1.3",
"hono": "^4.13.11"
},
"devDependencies": {
"@types/node": "^26.1.1",
"tsx": "^4.23.0",
"typescript": "^7.0.2"
}
}Añadirlo a un proyecto existente
# Node.js: Hono + el adaptador para el servidor HTTP de Node
npm install hono @hono/node-server
npm install -D typescript tsx @types/node
# Bun
bun add hono @hono/bun
# Deno (desde JSR)
deno add jsr:@hono/hono
# Comprobar la versión instalada
npm ls hono@hono/bun, @hono/deno, @hono/cloudflare-workers, @hono/aws-lambda, @hono/lambda-edge, @hono/netlify, @hono/vercel y @hono/service-worker. Las rutas antiguas (hono/bun, hono/deno…) siguen funcionando en la versión 4, pero están obsoletas y desaparecerán en la 5. Migrar es cambiar el import.El ejemplo mínimo: hola mundo
Este es el servidor más pequeño posible en Node.js. Crea src/index.ts:
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
const app = new Hono()
app.get('/', (c) => c.text('¡Hola, Hono!'))
app.get('/json', (c) => c.json({ mensaje: 'Hola', fecha: new Date().toISOString() }))
serve({ fetch: app.fetch, port: 3000 }, (info) => {
console.log(`Escuchando en http://localhost:${info.port}`)
})$ npx tsx src/index.ts
Escuchando en http://localhost:3000
$ curl -i http://localhost:3000/
HTTP/1.1 200 OK
Content-Type: text/plain; charset=UTF-8
Content-Length: 13
¡Hola, Hono!
$ curl http://localhost:3000/json
{"mensaje":"Hola","fecha":"2026-09-29T17:42:37.818Z"}Fíjate en tres cosas. La primera: app.get() recibe una ruta y un handler, una función que recibe el contexto c y devuelve una respuesta. La segunda: no hay req ni res como en Express; todo pasa por c, y lo que devuelves es la respuesta (no llamas a res.send()). La tercera: serve() es lo único específico de Node. En Bun, Deno o Workers cambiarías solo esas líneas finales.
Cómo funciona por dentro
Para usar Hono bien, y sobre todo para entender por qué es tan rápido y tan portable, merece la pena saber qué ocurre desde que llega una petición hasta que sale la respuesta.

Todo es fetch(Request) → Response
El corazón de Hono es el método app.fetch(request, env, executionCtx). Recibe un Request estándar (el mismo de la API Fetch del navegador), lo procesa y devuelve una promesa de Response. No sabe nada de sockets, puertos ni de IncomingMessage. Eso lo hace portable:
- Cloudflare Workers, Bun y Deno ya hablan
Request/Responsede forma nativa. Le pasan la petición directamente aapp.fetchy ya está. - Node.js no: su servidor HTTP trabaja con
IncomingMessageyServerResponse. Por eso existe@hono/node-server, que traduce en los dos sentidos. - AWS Lambda, Vercel o Netlify reciben eventos con su propio formato. Sus adaptadores (
handle(app)) convierten el evento enRequesty elResponseen lo que espera la plataforma.
El adaptador de Node ha mejorado muchísimo. Su versión 2, publicada en abril de 2026, llega a ser hasta 2,3 veces más rápida que la 1 en el escenario de procesar el cuerpo de la petición. El truco, explicado por el propio Yusuke Wada, es no hacer trabajo innecesario: el adaptador crea versiones "ligeras" de Request y Response, y solo construye el objeto Request real cuando algo lo necesita de verdad (por ejemplo, cuando llamas a c.req.json()). La API pública no cambió, así que actualizar es cambiar el número de versión.
Los cinco routers
El router decide qué handler (y qué middleware) corresponde a cada petición. Hono no tiene uno, sino cinco, cada uno con un equilibrio distinto entre velocidad de búsqueda, velocidad de registro y tamaño:
| Router | Cómo funciona | Cuándo brilla |
|---|---|---|
| RegExpRouter | Compila todas las rutas en una única expresión regular gigante y resuelve la ruta con un solo match, en lugar de recorrerlas una a una. | Servidores de larga vida: la búsqueda es la más rápida. Es el preferido por defecto. |
| TrieRouter | Un árbol (trie) donde cada nivel es un segmento de la ruta. | Soporta todos los patrones, incluidos los que RegExpRouter no puede representar. |
| SmartRouter | No es un router en sí: recibe una lista (por defecto RegExpRouter y TrieRouter), prueba el primero y, si no soporta tus rutas, pasa al siguiente. Después se queda con el elegido. | Es el que usa new Hono(). Te da lo más rápido sin que tengas que pensar. |
| LinearRouter | Guarda las rutas en una lista y las recorre. Registrar es casi gratis. | Entornos que se arrancan en cada petición (algunos serverless), donde el coste de preparar el router importa más que la búsqueda. Lo usa el preset hono/quick. |
| PatternRouter | Convierte cada ruta en su propia expresión regular. Es el más sencillo. | Cuando el tamaño del paquete es lo que manda. Lo usa el preset hono/tiny. |
He medido los cinco en este servidor con una API de 60 rutas (listados, detalle por id, rutas anidadas con dos parámetros, comodines y parámetros con expresión regular) y seis peticiones distintas, incluida una que no existe. Cada búsqueda se repite 200.000 veces:
import { RegExpRouter } from 'hono/router/reg-exp-router'
import { TrieRouter } from 'hono/router/trie-router'
import { LinearRouter } from 'hono/router/linear-router'
import { PatternRouter } from 'hono/router/pattern-router'
import { SmartRouter } from 'hono/router/smart-router'
// 60 rutas de una API "realista" y 6 peticiones de prueba
const rutas: string[] = []
for (const r of ['usuarios', 'posts', 'comentarios', 'etiquetas', 'pedidos', 'productos', 'facturas', 'clientes', 'proveedores', 'almacenes']) {
rutas.push(`/api/${r}`, `/api/${r}/:id`, `/api/${r}/:id/historial`, `/api/${r}/:id/adjuntos/:adjunto`, `/admin/${r}/*`, `/v2/${r}/:id{[0-9]+}`)
}
const peticiones = ['/api/usuarios', '/api/facturas/42', '/api/proveedores/7/adjuntos/99', '/admin/clientes/a/b', '/v2/almacenes/12', '/no/existe']
const routers = {
RegExpRouter: () => new RegExpRouter<string>(),
TrieRouter: () => new TrieRouter<string>(),
LinearRouter: () => new LinearRouter<string>(),
PatternRouter: () => new PatternRouter<string>(),
'SmartRouter (Reg+Trie)': () => new SmartRouter<string>({ routers: [new RegExpRouter(), new TrieRouter()] }),
}
for (const [nombre, crear] of Object.entries(routers)) {
const t0 = performance.now()
const router = crear()
for (const r of rutas) router.add('GET', r, r)
router.match('GET', '/api/usuarios') // la primera coincidencia "compila" en algunos routers
const registro = performance.now() - t0
const N = 200_000
const t1 = performance.now()
for (let i = 0; i < N; i++) router.match('GET', peticiones[i % peticiones.length])
const ns = ((performance.now() - t1) * 1e6) / N
console.log(`${nombre.padEnd(24)} registro+1ª: ${registro.toFixed(2).padStart(6)} ms búsqueda: ${ns.toFixed(0).padStart(5)} ns`)
}$ npx tsx src/routers.ts
RegExpRouter registro+1ª: 5.79 ms búsqueda: 723 ns
TrieRouter registro+1ª: 1.45 ms búsqueda: 1036 ns
LinearRouter registro+1ª: 0.97 ms búsqueda: 23375 ns
PatternRouter registro+1ª: 1.86 ms búsqueda: 2965 ns
SmartRouter (Reg+Trie) registro+1ª: 1.71 ms búsqueda: 920 ns
(Node.js 20.20, Hono 4.13.11; tres ejecuciones con resultados muy parecidos)Los resultados confirman la teoría: RegExpRouter es el más rápido buscando (unos 0,7 µs por petición) pero el más lento preparando; LinearRouter es el más rápido registrando y, con 60 rutas, el más lento buscando. SmartRouter queda prácticamente igual que RegExpRouter porque acaba eligiéndolo. Para una API en un servidor que vive horas o días, la configuración por defecto es la correcta. Ojo: es un microbenchmark del router aislado; en una petición real pesan mucho más la red, el JSON y la base de datos.
Elegir otro router o un preset es una línea:
// Preset por defecto: SmartRouter con RegExpRouter y TrieRouter
import { Hono } from 'hono'
// Preset "quick": arranque rápido (LinearRouter), para entornos que se inician en cada petición
import { Hono as HonoQuick } from 'hono/quick'
// Preset "tiny": el más pequeño (PatternRouter)
import { Hono as HonoTiny } from 'hono/tiny'
// O eligiendo el router a mano
import { RegExpRouter } from 'hono/router/reg-exp-router'
const app = new Hono({ router: new RegExpRouter() })Y lo que pesa cada preset, empaquetado con esbuild y minificado para una aplicación de una sola ruta:
| Import | Router | Minificado | Con gzip |
|---|---|---|---|
hono | SmartRouter (RegExp + Trie) | 18,4 KB | 7,7 KB |
hono/quick | SmartRouter (Linear + Trie) | 16,3 KB | 6,8 KB |
hono/tiny | PatternRouter | 11,7 KB | 5,0 KB |
UnsupportedPathError al arrancar en lugar de en la primera petición. Con el SmartRouter por defecto no lo verás nunca, porque pasa a TrieRouter automáticamente.El contexto: c
Cada petición crea un objeto Context. Es tu caja de herramientas y lo tienes en todos los handlers y middleware:
c.req: la petición.c.req.param(),c.req.query(),c.req.queries(),c.req.header(),c.req.json(),c.req.text(),c.req.formData(),c.req.parseBody(),c.req.valid()(datos ya validados), yc.req.rawpara elRequestoriginal.c.json(),c.text(),c.html(),c.body(),c.redirect(),c.notFound(): construyen la respuesta. Aceptan el código de estado y cabeceras como argumentos.c.header()yc.status(): modifican la respuesta que se está construyendo.c.set()yc.get()(oc.var): guardan datos durante la petición, por ejemplo el usuario autenticado. Se tipan con el genéricoVariables.c.env: las variables de entorno y los bindings de la plataforma (KV, D1, R2 en Cloudflare). En Node, lo habitual es seguir usandoprocess.env.c.executionCtx: en Workers, permitewaitUntil()para hacer trabajo después de responder.
El middleware en cebolla
Un middleware es una función (c, next) => …. Lo que escribes antes de await next() se ejecuta a la ida; lo que escribes después, a la vuelta, cuando ya existe la respuesta. Por eso un solo middleware puede medir tiempos, añadir cabeceras al final o capturar errores de todo lo que va dentro.

Es el mismo modelo que popularizó Koa y muy distinto del de Express, donde el middleware solo "pasa el turno" y no puede actuar fácilmente sobre la respuesta final.
Rutas
El sistema de rutas cubre todo lo habitual y algunas cosas menos habituales. Este fichero lo recoge casi todo; cada ruta la he probado con curl:
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
const app = new Hono()
// Métodos HTTP: get, post, put, patch, delete, options, query... y all()
app.get('/saludo', (c) => c.text('GET'))
app.post('/saludo', (c) => c.text('POST', 201))
app.on(['PUT', 'PATCH'], '/saludo', (c) => c.text(`Actualizado con ${c.req.method}`))
// Parámetros de ruta
app.get('/usuarios/:id', (c) => {
const id = c.req.param('id')
return c.json({ id })
})
// Varios parámetros a la vez (tipados automáticamente)
app.get('/posts/:postId/comentarios/:comentarioId', (c) => {
const { postId, comentarioId } = c.req.param()
return c.json({ postId, comentarioId })
})
// Parámetro opcional: /api/animales y /api/animales/gato
app.get('/api/animales/:tipo?', (c) => c.text(`tipo = ${c.req.param('tipo') ?? '(todos)'}`))
// Parámetro con expresión regular: solo fechas AAAA-MM-DD
app.get('/archivo/:fecha{[0-9]{4}-[0-9]{2}-[0-9]{2}}', (c) => c.text(`Archivo del ${c.req.param('fecha')}`))
// Comodín
app.get('/descargas/*', (c) => c.text(`Ruta pedida: ${c.req.path}`))
// Query string y cabeceras
app.get('/buscar', (c) => {
const q = c.req.query('q') // un valor
const etiquetas = c.req.queries('tag') // ?tag=a&tag=b -> ['a','b']
const agente = c.req.header('User-Agent')
return c.json({ q, etiquetas, agente })
})
// Encadenar métodos sobre la misma ruta
app
.get('/libro', (c) => c.text('Leer libro'))
.post((c) => c.text('Crear libro'))
.delete((c) => c.text('Borrar libro'))
// Agrupar rutas en otra instancia y montarla con route()
const v1 = new Hono().basePath('/v1')
v1.get('/estado', (c) => c.json({ ok: true, version: 1 }))
app.route('/api', v1) // -> /api/v1/estado
// Redirecciones
app.get('/viejo', (c) => c.redirect('/saludo', 301))
serve({ fetch: app.fetch, port: 3000 })Y lo que responde:
$ curl -X PATCH localhost:3000/saludo
Actualizado con PATCH
$ curl localhost:3000/posts/3/comentarios/9
{"postId":"3","comentarioId":"9"}
$ curl localhost:3000/api/animales
tipo = (todos)
$ curl localhost:3000/api/animales/gato
tipo = gato
$ curl localhost:3000/archivo/2026-09-29
Archivo del 2026-09-29
$ curl -i localhost:3000/archivo/ayer # no cumple la expresión regular
HTTP/1.1 404 Not Found
$ curl localhost:3000/descargas/a/b.zip
Ruta pedida: /descargas/a/b.zip
$ curl -A curl-demo "localhost:3000/buscar?q=hono&tag=a&tag=b"
{"q":"hono","etiquetas":["a","b"],"agente":"curl-demo"}
$ curl -X DELETE localhost:3000/libro
Borrar libro
$ curl localhost:3000/api/v1/estado
{"ok":true,"version":1}
$ curl -i localhost:3000/viejo
HTTP/1.1 301 Moved Permanently
location: /saludo- Los parámetros llegan tipados. En
/posts/:postId/comentarios/:comentarioId, TypeScript sabe quec.req.param()devuelve{ postId: string; comentarioId: string }. Si escribes mal el nombre, no compila. - Los parámetros siempre son texto.
/usuarios/7te da"7", no7. Para convertir y validar, usa un validador (lo vemos enseguida). - El orden importa. Las rutas y middleware se evalúan en el orden en que los registras. Un
app.get('*')al principio se lo come todo. app.route()monta sub-aplicaciones. Es la forma de dividir una API grande en ficheros: cada módulo exporta su propionew Hono()y la aplicación principal los monta con un prefijo.
app.query() y lo entienden los middleware de CORS, ETag y caché. Lo verás en el ejemplo de extras.Middleware: propios e integrados
Hono trae de serie casi todo lo que una API necesita. Este ejemplo combina un middleware propio con once integrados, con las variables del contexto bien tipadas:
import { Hono } from 'hono'
import { createMiddleware } from 'hono/factory'
import { serve } from '@hono/node-server'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { secureHeaders } from 'hono/secure-headers'
import { etag } from 'hono/etag'
import { compress } from 'hono/compress'
import { basicAuth } from 'hono/basic-auth'
import { jwt, sign, type JwtVariables } from 'hono/jwt'
import { requestId, type RequestIdVariables } from 'hono/request-id'
import { bodyLimit } from 'hono/body-limit'
import { timeout } from 'hono/timeout'
import { prettyJSON } from 'hono/pretty-json'
// Tipamos las variables que los middlewares guardan en el contexto
type Env = { Variables: JwtVariables & RequestIdVariables & { inicio: number } }
const app = new Hono<Env>()
// Middleware propio: mide cuánto tarda cada petición
const cronometro = createMiddleware<Env>(async (c, next) => {
c.set('inicio', performance.now())
await next() // aquí se ejecuta el resto
const ms = (performance.now() - c.get('inicio')).toFixed(2)
c.header('X-Tiempo-Respuesta', `${ms}ms`) // ya tenemos la respuesta
})
// Globales, en este orden
app.use(requestId())
app.use(logger())
app.use(cronometro)
app.use(secureHeaders())
app.use(prettyJSON()) // ?pretty para JSON indentado
app.use('/api/*', cors({ origin: ['https://devutilites.com'], credentials: true }))
app.use('/api/*', etag())
app.use('/api/*', compress()) // gzip / deflate según Accept-Encoding
// Límite de tamaño del cuerpo y tiempo máximo en las subidas
app.post(
'/api/subir',
bodyLimit({ maxSize: 1024 * 1024, onError: (c) => c.text('Demasiado grande', 413) }),
timeout(5000),
async (c) => {
const cuerpo = await c.req.arrayBuffer()
return c.json({ bytes: cuerpo.byteLength, id: c.get('requestId') })
},
)
app.get('/api/datos', (c) =>
c.json({ items: Array.from({ length: 200 }, (_, i) => ({ i, texto: 'Hono es rápido' })) }),
)
// Zona de administración con usuario y contraseña
app.use('/admin/*', basicAuth({ username: 'admin', password: 'secreto-largo' }))
app.get('/admin/panel', (c) => c.text('Bienvenido al panel'))
// Tokens JWT
const SECRETO = process.env.JWT_SECRET ?? 'cambia-esto-en-produccion'
app.post('/login', async (c) => {
const token = await sign(
{ sub: 'usuario-42', rol: 'editor', exp: Math.floor(Date.now() / 1000) + 60 * 60 },
SECRETO,
'HS256',
)
return c.json({ token })
})
app.use('/privado/*', jwt({ secret: SECRETO, alg: 'HS256' }))
app.get('/privado/perfil', (c) => {
const payload = c.get('jwtPayload') // { sub, rol, exp }
return c.json({ usuario: payload.sub, rol: payload.rol })
})
serve({ fetch: app.fetch, port: 3000 })Las cabeceras que devuelve GET /api/datos con un navegador de devutilites.com enseñan el trabajo de cada uno:
HTTP/1.1 200 OK
access-control-allow-credentials: true
access-control-allow-origin: https://devutilites.com <- cors
content-encoding: gzip <- compress
content-type: application/json
etag: "d0cb131bf901a19a1107ec6034ccdd3f8fede50f" <- etag
cross-origin-opener-policy: same-origin <- secureHeaders...
cross-origin-resource-policy: same-origin
origin-agent-cluster: ?1
referrer-policy: no-referrer
strict-transport-security: max-age=15552000; includeSubDomains
x-content-type-options: nosniff
x-dns-prefetch-control: off
x-download-options: noopen
x-frame-options: SAMEORIGIN
x-permitted-cross-domain-policies: none
x-xss-protection: 0 <- ...hasta aquí
vary: Accept-Encoding, Origin
x-request-id: ac6e59cd-218a-47ca-9400-6faf813a12e7 <- requestId
x-tiempo-respuesta: 21.40ms <- nuestro cronometroY el resto de comprobaciones: la segunda petición con If-None-Match devuelve 304 (ETag), el fichero de 2 MB se rechaza con 413, el panel pide contraseña (401 con WWW-Authenticate: Basic), y el perfil solo responde con un token válido:
# ETag: la segunda vez, el servidor responde "no ha cambiado"
$ curl -s -o /dev/null -w "%{http_code}\n" -H 'If-None-Match: "ce7ef36c6059dead6fa3f04652c98938eaa12b98"' localhost:3000/api/datos
304
# bodyLimit: 2 MB cuando el máximo es 1 MB
$ head -c 2000000 /dev/zero > grande.bin
$ curl -i -X POST --data-binary @grande.bin localhost:3000/api/subir
HTTP/1.1 413 Payload Too Large
Demasiado grande
# basicAuth
$ curl -i localhost:3000/admin/panel
HTTP/1.1 401 Unauthorized
www-authenticate: Basic realm="Secure Area"
$ curl -u admin:secreto-largo localhost:3000/admin/panel
Bienvenido al panel
# JWT: pedimos un token y lo usamos (?pretty activa prettyJSON)
$ TOKEN=$(curl -s -X POST localhost:3000/login | jq -r .token)
$ curl -H "Authorization: Bearer $TOKEN" "localhost:3000/privado/perfil?pretty"
{
"usuario": "usuario-42",
"rol": "editor"
}
$ curl -i localhost:3000/privado/perfil
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="http://localhost:3000/privado/perfil",error="invalid_request",error_description="no authorization included in request"
# Y lo que escribe logger() en la consola
<-- GET /api/datos
--> GET /api/datos 200 22ms
<-- GET /api/datos
--> GET /api/datos 304 3ms
<-- POST /api/subir
--> POST /api/subir 413 2msLos middleware integrados más útiles
| Import | Para qué sirve |
|---|---|
hono/logger | Registra cada petición y su estado y duración. |
hono/cors | Cabeceras CORS y respuesta a las peticiones preflight. |
hono/secure-headers | Equivalente a Helmet: HSTS, X-Frame-Options, CSP, Permissions-Policy… |
hono/csrf | Protección CSRF comprobando Origin y Sec-Fetch-Site. |
hono/basic-auth, hono/bearer-auth | Usuario y contraseña, o token fijo. |
hono/jwt, hono/jwk | Tokens JWT firmados con secreto o con claves públicas (JWKS). |
hono/etag, hono/cache | Respuestas 304 y caché con la Cache API (Workers, Deno). |
hono/compress | Compresión gzip o deflate con CompressionStream. |
hono/body-limit, hono/timeout | Tamaño máximo del cuerpo y tiempo máximo de respuesta. |
hono/request-id | Un identificador por petición, para seguirla en los logs. |
hono/ip-restriction | Listas de IP permitidas o denegadas. |
hono/method-not-allowed | Nuevo en 4.13: 405 con cabecera Allow. |
hono/context-storage | Acceder al contexto desde cualquier función, con AsyncLocalStorage. |
hono/timing | Cabecera Server-Timing para ver tiempos en las herramientas del navegador. |
jwt() exige indicar el algoritmo (alg: 'HS256'). Además, desde la 4.12.21 solo acepta el esquema Bearer en la cabecera Authorization. Si actualizas desde una versión antigua y todo devuelve 401, revisa esas dos cosas. Y nunca dejes el secreto en el código: léelo de una variable de entorno.Validación con Zod
Los datos que llegan del exterior no son de fiar. Hono tiene un validador genérico (hono/validator) y paquetes oficiales para Zod, Valibot, ArkType, TypeBox y cualquier librería compatible con Standard Schema (@hono/standard-validator). El más usado es @hono/zod-validator:
npm install @hono/zod-validator zodPuedes validar json, form, query, param, header y cookie. Lo validado se lee con c.req.valid('json') y sale tipado y transformado: si el esquema convierte "7" en 7, tu handler recibe el número. En la API de más abajo verás un envoltorio que devuelve siempre el mismo formato de error:
$ curl -X POST localhost:3000/api/tareas -H 'Content-Type: application/json' \
-d '{"titulo":"","prioridad":"urgentísima"}'
{"error":"VALIDACION","detalles":{"titulo":["El título es obligatorio"],"prioridad":["Invalid option: expected one of \"baja\"|\"media\"|\"alta\""]}}
$ curl "localhost:3000/api/tareas?limite=500"
{"error":"VALIDACION","detalles":{"limite":["Too big: expected number to be <=100"]}}
$ curl localhost:3000/api/tareas/abc
{"error":"VALIDACION","detalles":{"id":["Invalid input: expected number, received NaN"]}}zValidator una función de error propia con parámetros escritos a mano (o any), TypeScript pierde el tipo de la respuesta de error y el cliente RPC se queja. La solución es la del envoltorio validar() del ejemplo: una función genérica que llama a zValidator y deja que Hono infiera el resto.Manejo de errores
Hono centraliza los errores en dos sitios: app.notFound() para las rutas que no existen y app.onError() para cualquier excepción lanzada en un handler o middleware. Para errores "esperados" (petición mal hecha, falta de permisos) se lanza una HTTPException con su código:
import { Hono } from 'hono'
import { HTTPException } from 'hono/http-exception'
import { serve } from '@hono/node-server'
const app = new Hono()
app.get('/articulos/:id', (c) => {
const id = Number(c.req.param('id'))
if (!Number.isInteger(id)) {
throw new HTTPException(400, { message: 'El id debe ser un número' })
}
if (id > 100) {
// También se puede pasar una respuesta completa
throw new HTTPException(404, {
res: c.json({ error: 'NO_EXISTE', id }, 404),
})
}
return c.json({ id, titulo: `Artículo ${id}` })
})
app.get('/explota', () => {
throw new Error('Algo ha ido muy mal')
})
// Rutas que no existen
app.notFound((c) => c.json({ error: 'NOT_FOUND', ruta: c.req.path }, 404))
// Cualquier error lanzado en un handler o middleware
app.onError((err, c) => {
if (err instanceof HTTPException) {
// Si trae respuesta propia, la usamos tal cual
return err.res ?? c.json({ error: err.message }, err.status)
}
console.error(err)
return c.json({ error: 'ERROR_INTERNO' }, 500)
})
serve({ fetch: app.fetch, port: 3000 })$ curl localhost:3000/articulos/5
{"id":5,"titulo":"Artículo 5"}
$ curl -i localhost:3000/articulos/abc
HTTP/1.1 400 Bad Request
{"error":"El id debe ser un número"}
$ curl -i localhost:3000/articulos/500
HTTP/1.1 404 Not Found
{"error":"NO_EXISTE","id":500}
$ curl -i localhost:3000/explota
HTTP/1.1 500 Internal Server Error
{"error":"ERROR_INTERNO"}
$ curl -i localhost:3000/nada
HTTP/1.1 404 Not Found
{"error":"NOT_FOUND","ruta":"/nada"}El /explota devuelve un 500 limpio al cliente, sin filtrar la traza, mientras en el log del servidor queda el error completo. Así debe ser en producción.
HTML con JSX y plantillas
Hono incluye su propio motor JSX (hono/jsx), que renderiza a texto en el servidor, sin React. Solo hay que decírselo a TypeScript en el tsconfig.json:
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"types": ["node"],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"noEmit": true
},
"include": ["src"]
}Y ya puedes escribir componentes en ficheros .tsx:
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { html, raw } from 'hono/html'
import { jsxRenderer } from 'hono/jsx-renderer'
import type { FC, PropsWithChildren } from 'hono/jsx'
type Articulo = { slug: string; titulo: string; etiquetas: string[] }
const articulos: Articulo[] = [
{ slug: 'hono-a-fondo', titulo: 'Hono a fondo', etiquetas: ['backend', 'typescript'] },
{ slug: 'fastify-a-fondo', titulo: 'Fastify a fondo', etiquetas: ['backend'] },
]
// Componentes: funciones que devuelven JSX, sin React ni DOM virtual en el servidor
const Etiqueta: FC<{ nombre: string }> = ({ nombre }) => <span class="etiqueta">#{nombre}</span>
const Tarjeta: FC<{ articulo: Articulo }> = ({ articulo }) => (
<article>
<h2><a href={`/${articulo.slug}/`}>{articulo.titulo}</a></h2>
{articulo.etiquetas.map((e) => <Etiqueta nombre={e} />)}
</article>
)
const Layout: FC<PropsWithChildren<{ titulo: string }>> = ({ titulo, children }) => (
<html lang="es">
<head>
<meta charset="utf-8" />
<title>{titulo}</title>
</head>
<body>
<header><h1>{titulo}</h1></header>
<main>{children}</main>
</body>
</html>
)
const app = new Hono()
// Opción 1: devolver JSX directamente con c.html()
app.get('/', (c) =>
c.html(
<Layout titulo="Artículos">
{articulos.map((a) => <Tarjeta articulo={a} />)}
</Layout>,
),
)
// Opción 2: un layout común con jsxRenderer y c.render()
app.use('/paginas/*', jsxRenderer(({ children }) => <Layout titulo="Mi web">{children}</Layout>))
app.get('/paginas/sobre-mi', (c) => c.render(<p>Escribo sobre servidores y backend.</p>))
// El contenido se escapa: esto NO ejecuta el script
app.get('/buscar', (c) => {
const q = c.req.query('q') ?? ''
return c.html(<p>Has buscado: {q}</p>)
})
// Opción 3: plantillas con la etiqueta html``, sin JSX
app.get('/plantilla', (c) => {
const nombre = c.req.query('nombre') ?? 'mundo'
return c.html(html`<!doctype html>
<p>Hola, ${nombre}</p>
${raw('<hr>')}`)
})
serve({ fetch: app.fetch, port: 3000 })$ curl localhost:3000/
<html lang="es"><head><meta charset="utf-8"/><title>Artículos</title></head><body><header><h1>Artículos</h1></header><main><article><h2><a href="/hono-a-fondo/">Hono a fondo</a></h2><span class="etiqueta">#backend</span><span class="etiqueta">#typescript</span></article>...</main></body></html>
$ curl localhost:3000/paginas/sobre-mi
<!DOCTYPE html><html lang="es"><head><meta charset="utf-8"/><title>Mi web</title></head><body><header><h1>Mi web</h1></header><main><p>Escribo sobre servidores y backend.</p></main></body></html>
$ curl "localhost:3000/buscar?q=%3Cscript%3Ealert(1)%3C/script%3E"
<p>Has buscado: <script>alert(1)</script></p>
$ curl "localhost:3000/plantilla?nombre=%3Cb%3EAna%3C/b%3E"
<!doctype html>
<p>Hola, <b>Ana</b></p>
<hr>Los valores que insertas con llaves se escapan automáticamente: la búsqueda maliciosa sale como texto inofensivo. Con la plantilla html pasa igual; solo raw() inserta HTML sin escapar, así que úsalo únicamente con contenido tuyo. jsxRenderer añade el <!DOCTYPE html> y te permite definir el layout una vez.
El JSX de Hono también sirve en el navegador (hono/jsx/dom), tiene hooks compatibles con React (useState, useEffect, useRef…), Suspense con streaming y componentes asíncronos. Para aplicaciones completas con islas interactivas existe HonoX.
hono/jsx: contexto compartido entre peticiones (4.12.27), memo() reutilizando HTML de otro usuario (4.12.34) y cadenas sin escapar en componentes boundary (4.13.7). Con la 4.13.11 estás cubierto.Streaming y Server-Sent Events
Enviar la respuesta por partes es esencial para chats con IA, progreso de tareas largas o paneles en directo. hono/streaming ofrece tres funciones:
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { stream, streamText, streamSSE } from 'hono/streaming'
const app = new Hono()
// Texto que llega poco a poco (como la respuesta de un modelo de IA)
app.get('/texto', (c) =>
streamText(c, async (s) => {
for (const palabra of ['Hono', 'envía', 'esto', 'por', 'trozos.']) {
await s.write(palabra + ' ')
await s.sleep(200)
}
}),
)
// Server-Sent Events: el navegador los recibe con new EventSource('/eventos')
app.get('/eventos', (c) =>
streamSSE(c, async (s) => {
let id = 0
s.onAbort(() => console.log('El cliente se ha desconectado'))
while (id < 3) {
await s.writeSSE({
id: String(id++),
event: 'hora',
data: JSON.stringify({ hora: new Date().toISOString() }),
})
await s.sleep(1000)
}
}),
)
// Binario: reenviar un ReadableStream (un fichero, otra API...)
app.get('/binario', (c) =>
stream(c, async (s) => {
s.onAbort(() => console.log('Abortado'))
const origen = new Blob(['datos binarios de ejemplo\n'.repeat(3)]).stream()
await s.pipe(origen)
}),
)
serve({ fetch: app.fetch, port: 3000 })$ curl -N localhost:3000/texto # -N: sin buffer, se ve llegar palabra a palabra
Hono envía esto por trozos.
$ curl -N localhost:3000/eventos
event: hora
data: {"hora":"2026-09-29T17:44:58.975Z"}
id: 0
event: hora
data: {"hora":"2026-09-29T17:44:59.977Z"}
id: 1
event: hora
data: {"hora":"2026-09-29T17:45:00.984Z"}
id: 2
$ curl localhost:3000/binario
datos binarios de ejemplo
datos binarios de ejemplo
datos binarios de ejemploEn el navegador, los eventos se reciben con muy poco código:
const fuente = new EventSource('/eventos')
fuente.addEventListener('hora', (e) => {
const { hora } = JSON.parse(e.data)
document.querySelector('#hora').textContent = hora
})
// EventSource se reconecta solo; ciérralo cuando ya no lo necesites
window.addEventListener('beforeunload', () => fuente.close())Transfer-Encoding: chunked; en Nginx añade proxy_buffering off o la cabecera X-Accel-Buffering: no.Una API CRUD completa
Vamos a juntar todo en algo realista: una API de tareas con validación, errores en JSON, cabeceras de seguridad, cliente tipado y pruebas. La estructura del proyecto:
mi-api/
├── Dockerfile
├── package.json
├── tsconfig.json
├── tsconfig.build.json
└── src/
└── api/
├── tareas.ts # rutas y validación
├── app.ts # aplicación: middleware, errores, rutas montadas
├── servidor.ts # arranque en Node.js
├── cliente.ts # ejemplo de cliente RPC tipado
└── tareas.test.ts # pruebas con VitestLas rutas: src/api/tareas.ts
import { Hono, type ValidationTargets } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
// --- Esquemas ---
const idParam = z.object({ id: z.coerce.number().int().positive() })
const titulo = z.string().trim().min(1, 'El título es obligatorio').max(120)
const prioridad = z.enum(['baja', 'media', 'alta'])
const nuevaTarea = z.object({
titulo,
prioridad: prioridad.default('media'),
})
// Para PATCH, todo opcional y SIN valores por defecto (ver el aviso del artículo)
const cambiosTarea = z.object({
titulo: titulo.optional(),
prioridad: prioridad.optional(),
hecha: z.boolean().optional(),
})
const filtros = z.object({
hecha: z.enum(['true', 'false']).transform((v) => v === 'true').optional(),
limite: z.coerce.number().int().min(1).max(100).default(20),
})
export type Tarea = z.infer<typeof nuevaTarea> & { id: number; hecha: boolean; creada: string }
// --- "Base de datos" en memoria (cámbiala por Postgres, SQLite, D1...) ---
const db = new Map<number, Tarea>()
let siguienteId = 1
// Envoltorio de zValidator: misma respuesta 422 para cualquier error de validación
const validar = <T extends z.ZodType, Target extends keyof ValidationTargets>(target: Target, schema: T) =>
zValidator(target, schema, (resultado, c) => {
if (!resultado.success) {
return c.json({ error: 'VALIDACION', detalles: z.flattenError(resultado.error).fieldErrors }, 422)
}
})
// Encadenar las rutas es lo que permite que el cliente RPC conozca los tipos
export const tareas = new Hono()
.get('/', validar('query', filtros), (c) => {
const { hecha, limite } = c.req.valid('query')
let lista = [...db.values()]
if (hecha !== undefined) lista = lista.filter((t) => t.hecha === hecha)
return c.json({ total: lista.length, tareas: lista.slice(0, limite) })
})
.get('/:id', validar('param', idParam), (c) => {
const { id } = c.req.valid('param')
const tarea = db.get(id)
if (!tarea) return c.json({ error: 'NO_EXISTE' }, 404)
return c.json(tarea, 200)
})
.post('/', validar('json', nuevaTarea), (c) => {
const datos = c.req.valid('json')
const tarea: Tarea = { id: siguienteId++, hecha: false, creada: new Date().toISOString(), ...datos }
db.set(tarea.id, tarea)
c.header('Location', `/api/tareas/${tarea.id}`)
return c.json(tarea, 201)
})
.patch('/:id', validar('param', idParam), validar('json', cambiosTarea), (c) => {
const { id } = c.req.valid('param')
const tarea = db.get(id)
if (!tarea) return c.json({ error: 'NO_EXISTE' }, 404)
const actualizada = { ...tarea, ...c.req.valid('json') }
db.set(id, actualizada)
return c.json(actualizada, 200)
})
.delete('/:id', validar('param', idParam), (c) => {
const { id } = c.req.valid('param')
if (!db.delete(id)) return c.json({ error: 'NO_EXISTE' }, 404)
return c.body(null, 204)
})Dos detalles importantes. Las rutas están encadenadas (new Hono().get(…).post(…)) en lugar de escritas en sentencias separadas: así el tipo de tareas acumula todas las rutas y el cliente RPC puede conocerlas. Y el almacén es un Map en memoria para que el ejemplo funcione sin nada más; en un proyecto real lo cambiarías por Drizzle, Prisma o Kysely contra PostgreSQL o SQLite, o por D1 en Cloudflare.
nuevaTarea.partial(). Parece lógico, pero partial() conserva el .default('media') de la prioridad: al enviar solo { "hecha": true }, Zod rellenaba prioridad: "media" y una tarea de prioridad alta se degradaba sin que nadie lo pidiera. Por eso el esquema de cambios se escribe aparte, con todos los campos .optional() y sin valores por defecto, y hay una prueba que lo comprueba.La aplicación: src/api/app.ts
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { secureHeaders } from 'hono/secure-headers'
import { HTTPException } from 'hono/http-exception'
import { tareas } from './tareas.js'
export const app = new Hono()
.use(logger())
.use(secureHeaders())
.get('/health', (c) => c.json({ ok: true }))
.route('/api/tareas', tareas)
app.notFound((c) => c.json({ error: 'NOT_FOUND' }, 404))
app.onError((err, c) => {
if (err instanceof HTTPException) return err.getResponse()
console.error(err)
return c.json({ error: 'ERROR_INTERNO' }, 500)
})
// Este tipo es todo lo que necesita el cliente: no se comparte código de servidor
export type AppType = typeof appEl arranque: src/api/servidor.ts
import { serve } from '@hono/node-server'
import { app } from './app.js'
const port = Number(process.env.PORT ?? 3000)
const server = serve({ fetch: app.fetch, port, hostname: '0.0.0.0' }, (info) =>
console.log(`API de tareas en http://localhost:${info.port}`),
)
// Cierre ordenado: Coolify/Docker envían SIGTERM al desplegar
process.on('SIGTERM', () => {
server.close(() => process.exit(0))
})Escuchar en 0.0.0.0 es imprescindible dentro de Docker: si escuchas solo en 127.0.0.1, el proxy no puede llegar a tu aplicación y verás un Bad Gateway. Y atender SIGTERM permite cerrar las conexiones abiertas cuando Coolify o Docker paran el contenedor.
Probarla con curl
$ npm run dev
API de tareas en http://localhost:3000
$ curl -i -X POST localhost:3000/api/tareas -H 'Content-Type: application/json' \
-d '{"titulo":"Comprar café","prioridad":"alta"}'
HTTP/1.1 201 Created
content-type: application/json
location: /api/tareas/1
{"id":1,"hecha":false,"creada":"2026-09-29T17:59:58.694Z","titulo":"Comprar café","prioridad":"alta"}
$ curl -X POST localhost:3000/api/tareas -H 'Content-Type: application/json' \
-d '{"titulo":"Revisar copias de seguridad"}'
{"id":2,"hecha":false,"creada":"2026-09-29T17:59:58.717Z","titulo":"Revisar copias de seguridad","prioridad":"media"}
$ curl -X PATCH localhost:3000/api/tareas/1 -H 'Content-Type: application/json' -d '{"hecha":true}'
{"id":1,"hecha":true,"creada":"2026-09-29T18:00:17.121Z","titulo":"Comprar café","prioridad":"alta"}
$ curl "localhost:3000/api/tareas?hecha=true"
{"total":1,"tareas":[{"id":1,"hecha":true,"creada":"2026-09-29T18:00:17.121Z","titulo":"Comprar café","prioridad":"alta"}]}
$ curl -s -o /dev/null -w "%{http_code}\n" -X DELETE localhost:3000/api/tareas/2
204
$ curl localhost:3000/api/tareas/2
{"error":"NO_EXISTE"}El cliente RPC: tipos de extremo a extremo
Esta es probablemente la función que más engancha de Hono. Exportas el tipo de tu aplicación (export type AppType = typeof app) y, desde el frontend o desde otro servicio, creas un cliente con hc. No hay generación de código ni esquema OpenAPI intermedio: TypeScript deduce las rutas, los parámetros, el cuerpo que esperan y lo que devuelven.
import { hc } from 'hono/client'
import type { AppType } from './app.js'
const api = hc<AppType>('http://localhost:3000')
// Autocompletado: api.api.tareas.$get, $post, [':id'].$patch...
const creada = await api.api.tareas.$post({ json: { titulo: 'Escribir el artículo de Hono', prioridad: 'alta' } })
if (creada.status === 201) {
const tarea = await creada.json() // tipo Tarea, inferido desde el servidor
console.log('Creada:', tarea.id, tarea.titulo)
const res = await api.api.tareas[':id'].$patch({
param: { id: String(tarea.id) },
json: { hecha: true },
})
if (res.ok) console.log('Hecha:', (await res.json()).hecha)
}
const lista = await api.api.tareas.$get({ query: { hecha: 'true' } })
if (lista.ok) {
const { total, tareas } = await lista.json()
console.log(`${total} tarea(s) hechas:`, tareas.map((t) => t.titulo))
}
// Una URL tipada, útil para <a href> o fetch manuales
console.log(api.api.tareas[':id'].$url({ param: { id: '1' } }).href)$ npx tsx src/api/cliente.ts
Creada: 1 Escribir el artículo de Hono
Hecha: true
1 tarea(s) hechas: [ 'Escribir el artículo de Hono' ]
http://localhost:3000/api/tareas/1Si mañana cambias titulo por nombre en el esquema del servidor, el cliente deja de compilar en el sitio exacto donde lo usas. En un monorepo con frontend y backend, esto elimina una categoría entera de errores. Tres consejos:
- Importa el tipo con
import type, para que el código del servidor no acabe en el paquete del navegador. - Encadena las rutas y devuelve siempre
c.json(datos, código)con el código explícito; así el cliente puede distinguir cada respuesta porres.statusores.ok. - En APIs muy grandes, el editor puede ir lento calculando tipos. La documentación recomienda compilar el tipo de antemano con
tscy usar el.d.tsresultante.
Pruebas con Vitest
Probar una aplicación Hono es especialmente cómodo porque app.request() ejecuta la aplicación en memoria, sin abrir puertos: le pasas una ruta y opciones como a fetch() y te devuelve un Response. Y testClient() te da el cliente tipado sobre la misma app:
npm install -D vitestimport { describe, it, expect } from 'vitest'
import { testClient } from 'hono/testing'
import { app } from './app.js'
describe('API de tareas', () => {
it('responde al health check', async () => {
const res = await app.request('/health')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ ok: true })
})
it('crea, lee, actualiza y borra una tarea', async () => {
const creada = await app.request('/api/tareas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ titulo: 'Probar Hono' }),
})
expect(creada.status).toBe(201)
const tarea = await creada.json()
expect(tarea).toMatchObject({ titulo: 'Probar Hono', prioridad: 'media', hecha: false })
expect(creada.headers.get('Location')).toBe(`/api/tareas/${tarea.id}`)
const patch = await app.request(`/api/tareas/${tarea.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hecha: true }),
})
// Cambia solo lo que se envía: la prioridad no vuelve al valor por defecto
expect(await patch.json()).toMatchObject({ hecha: true, prioridad: 'media', titulo: 'Probar Hono' })
const borrada = await app.request(`/api/tareas/${tarea.id}`, { method: 'DELETE' })
expect(borrada.status).toBe(204)
expect((await app.request(`/api/tareas/${tarea.id}`)).status).toBe(404)
})
it('un PATCH parcial no pisa los demás campos', async () => {
const creada = await app.request('/api/tareas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ titulo: 'Urgente', prioridad: 'alta' }),
})
const { id } = await creada.json()
const patch = await app.request(`/api/tareas/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hecha: true }),
})
expect(await patch.json()).toMatchObject({ hecha: true, prioridad: 'alta' })
})
it('rechaza datos inválidos con 422', async () => {
const res = await app.request('/api/tareas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ titulo: '', prioridad: 'urgentísima' }),
})
expect(res.status).toBe(422)
const cuerpo = await res.json()
expect(cuerpo.detalles).toHaveProperty('titulo')
expect(cuerpo.detalles).toHaveProperty('prioridad')
})
it('funciona también con el cliente tipado', async () => {
const client = testClient(app)
const res = await client.api.tareas.$post({ json: { titulo: 'Con testClient', prioridad: 'baja' } })
expect(res.status).toBe(201)
const lista = await client.api.tareas.$get({ query: {} })
expect(lista.ok).toBe(true)
if (lista.ok) {
// Dentro del if, TypeScript sabe que no es la respuesta 422
const { total } = await lista.json()
expect(total).toBeGreaterThan(0)
}
})
it('devuelve 404 en JSON para rutas desconocidas', async () => {
const res = await app.request('/no-existe')
expect(res.status).toBe(404)
expect(await res.json()).toEqual({ error: 'NOT_FOUND' })
})
})$ npx vitest run
RUN v4.1.11 /home/yo/mi-api
Test Files 1 passed (1)
Tests 6 passed (6)
Duration 500ms (transform 91ms, setup 0ms, import 230ms, tests 75ms, environment 0ms)Seis pruebas, con todo el ciclo de la API, en medio segundo. Como no hay servidor de por medio, puedes lanzar cientos de estas pruebas en paralelo en tu CI.
Extras: 405, QUERY, cookies e IP del cliente
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { methodNotAllowed } from 'hono/method-not-allowed'
import { getConnInfo } from '@hono/node-server/conninfo'
import { getCookie, setCookie } from 'hono/cookie'
const app = new Hono()
// Novedad de 4.13: 405 con cabecera Allow en lugar de 404
app.use(methodNotAllowed({ app }))
// Novedad de 4.13: método QUERY (RFC 10008), una búsqueda "segura" con cuerpo
app.query('/buscar', async (c) => {
const { texto } = await c.req.json<{ texto: string }>()
return c.json({ resultados: [`Resultado para "${texto}"`] })
})
app.get('/hola', (c) => c.text('Hola'))
app.post('/hola', (c) => c.text('Recibido'))
// Cookies e IP del cliente
app.get('/visita', (c) => {
const visitas = Number(getCookie(c, 'visitas') ?? 0) + 1
setCookie(c, 'visitas', String(visitas), { httpOnly: true, sameSite: 'Lax', maxAge: 60 * 60 * 24 })
const ip = getConnInfo(c).remote.address
return c.json({ visitas, ip })
})
serve({ fetch: app.fetch, port: 3000 })$ curl -i -X PUT localhost:3000/hola
HTTP/1.1 405 Method Not Allowed
allow: GET, HEAD, POST
$ curl -X QUERY localhost:3000/buscar -H 'Content-Type: application/json' -d '{"texto":"hono"}'
{"resultados":["Resultado para \"hono\""]}
$ curl -c cookies.txt -b cookies.txt localhost:3000/visita
{"visitas":1,"ip":"::1"}
$ curl -c cookies.txt -b cookies.txt localhost:3000/visita
{"visitas":2,"ip":"::1"}Desplegar en cada runtime
Aquí se ve la gran ventaja de Hono. La aplicación es la misma; solo cambia cómo se arranca. Estos ejemplos los he ejecutado todos.
Node.js
Ya lo has visto: @hono/node-server y serve(). Para servir ficheros estáticos en Node se usa serveStatic de @hono/node-server/serve-static, y para WebSockets, upgradeWebSocket del mismo paquete.
Bun
Bun entiende directamente un objeto con fetch exportado por defecto. No hace falta adaptador para el servidor, solo para los ficheros estáticos:
import { Hono } from 'hono'
import { serveStatic } from '@hono/bun'
const app = new Hono()
app.use('/static/*', serveStatic({ root: './' }))
app.get('/', (c) => c.text(`Hola desde Bun ${Bun.version}`))
// Bun entiende este objeto: { port, fetch }
export default {
port: 3000,
fetch: app.fetch,
}$ bun add hono @hono/bun
$ bun run index.ts
Started development server: http://localhost:3000
$ curl localhost:3000/
Hola desde Bun 1.4.2
$ curl localhost:3000/static/leeme.txt
fichero estáticoDeno
Deno puede importar Hono desde npm o desde JSR, y Deno.serve() acepta app.fetch tal cual:
import { Hono } from 'npm:hono@^4.13'
const app = new Hono()
app.get('/', (c) => c.text(`Hola desde Deno ${Deno.version.deno}`))
Deno.serve({ port: 8000 }, app.fetch)$ deno run --allow-net main.ts
Listening on http://0.0.0.0:8000/ (http://localhost:8000/)
$ curl localhost:8000/
Hola desde Deno 2.9.7Cloudflare Workers
El entorno para el que nació Hono. Basta con exportar la aplicación. Las variables y los recursos de Cloudflare (KV, D1, R2, colas…) llegan tipados en c.env:
import { Hono } from 'hono'
// Las variables y bindings (KV, D1, R2, secretos) llegan tipados en c.env
type Bindings = {
SALUDO: string
VISITAS: KVNamespace
}
const app = new Hono<{ Bindings: Bindings }>()
app.get('/', async (c) => {
const visitas = Number((await c.env.VISITAS.get('total')) ?? 0) + 1
await c.env.VISITAS.put('total', String(visitas))
return c.json({ mensaje: c.env.SALUDO, visitas, pais: c.req.raw.cf?.country ?? 'desconocido' })
})
// En Workers basta con exportar la app: tiene el método fetch que espera la plataforma
export default app{
"name": "hono-en-workers",
"main": "src/index.ts",
"compatibility_date": "2026-09-01",
"vars": { "SALUDO": "Hola desde el borde" },
"kv_namespaces": [{ "binding": "VISITAS", "id": "id-del-namespace-kv" }]
}$ npm create hono@latest mi-worker -- --template cloudflare-workers # o a mano:
$ npm install hono && npm install -D wrangler @cloudflare/workers-types
$ npx wrangler dev
Your Worker has access to the following bindings:
Binding Resource Mode
env.VISITAS (id-del-namespace-kv) KV Namespace local
env.SALUDO ("Hola desde el borde") Environment Variable local
[wrangler:info] Ready on http://localhost:8787
$ curl localhost:8787/
{"mensaje":"Hola desde el borde","visitas":1,"pais":"FI"}
$ curl localhost:8787/
{"mensaje":"Hola desde el borde","visitas":2,"pais":"FI"}
$ npx wrangler deploy # publicar en CloudflareEn local, Wrangler simula el almacén KV (por eso el contador sube) y cf.country sale de tu IP real. Con npx wrangler deploy se publica en la red de Cloudflare. Consejo: en lugar de declarar a mano los tipos de Bindings, ejecuta npx wrangler types y usa el tipo Env que genera.
compatibility_date que tu Wrangler entienda. Con una versión vieja de Wrangler y una fecha reciente, wrangler dev falla con "This Worker requires compatibility date … but the newest date supported by this server binary is …". Me pasó al probar este ejemplo: actualizar Wrangler lo arregla.Docker y Coolify (tu propio servidor)
Para desplegar la API de tareas en un servidor propio con Coolify o cualquier otra plataforma basada en Docker, estos son los scripts del package.json:
"scripts": {
"dev": "tsx watch src/api/servidor.ts",
"build": "tsc -p tsconfig.build.json",
"start": "node dist/api/servidor.js",
"test": "vitest run"
}
// tsconfig.build.json: compila solo el servidor, sin pruebas ni el cliente de ejemplo
{
"extends": "./tsconfig.json",
"compilerOptions": { "noEmit": false, "outDir": "dist", "rootDir": "src" },
"include": ["src/api"],
"exclude": ["src/**/*.test.ts", "src/api/cliente.ts"]
}Y un Dockerfile en dos etapas: la primera compila TypeScript, la segunda solo lleva las dependencias de producción y el JavaScript resultante:
# --- Etapa 1: compilar TypeScript ---
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig*.json ./
COPY src ./src
RUN npm run build
# --- Etapa 2: solo lo necesario para ejecutar ---
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production PORT=3000
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
# Coolify usa este health check si lo defines aquí (wget viene en Alpine)
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
CMD wget -qO- http://127.0.0.1:3000/health || exit 1
CMD ["node", "dist/api/servidor.js"]$ docker build -t mi-api .
$ docker run -d --name mi-api -p 3000:3000 mi-api
$ docker ps --format '{{.Names}} {{.Status}}'
mi-api Up 15 seconds (healthy)
$ curl localhost:3000/health
{"ok":true}
$ docker stats --no-stream --format '{{.MemUsage}}' mi-api
30.82MiB / 7.57GiBLa imagen final ocupa 180 MB (casi todo es la propia imagen de Node) y el contenedor en marcha usa unos 31 MB de RAM. En Coolify eliges el build pack Dockerfile, pones el puerto 3000, la ruta de health check /health y el dominio; el resto lo hace solo, incluidos los despliegues sin cortes.
Errores comunes (y cómo evitarlos)
- Olvidar el
return. En Hono la respuesta es lo que devuelve el handler.c.json(datos)sinreturnproduce un 404 o un error de "Context is not finalized". - Registrar el middleware después de las rutas. El orden importa:
app.use(cors())escrito debajo deapp.get('/api')no se aplica a esa ruta. - Leer el cuerpo dos veces a mano.
c.req.json()guarda el resultado en caché, pero si usasc.req.raw.json()directamente, la segunda lectura falla porque el stream ya se consumió. - Rutas no encadenadas con RPC. Si escribes
app.get(…)en líneas separadas,typeof appno incluye esas rutas y el clientehcno las ve. - Escuchar en 127.0.0.1 dentro de Docker. El resultado es un 502 Bad Gateway en el proxy. Usa
hostname: '0.0.0.0'. - Usar APIs de Node en Workers.
fs,pathoprocess.envno existen (o existen parcialmente connodejs_compat). Si quieres portabilidad real, usac.envy APIs web. serveStaticimportado del sitio equivocado. Cada runtime tiene el suyo:@hono/node-server/serve-staticen Node,@hono/bunen Bun,@hono/denoen Deno.- Middleware que no llama a
next(). Si un middleware ni devuelve una respuesta ni llama aawait next(), la petición se queda colgada o termina en 404. - No actualizar. Hono publica correcciones de seguridad con frecuencia (CORS, JWT, cookies,
serveStatic,parseBody…). Unnpm outdatedde vez en cuando es obligatorio.
Hono frente a los demás, en una tabla
| Hono | Express | Fastify | NestJS | |
|---|---|---|---|---|
| Base | Estándares web (Request/Response) | API http de Node | API http de Node | Express o Fastify por debajo |
| Runtimes | Todos (Node, Bun, Deno, Workers, Lambda…) | Node (y Bun por compatibilidad) | Node (y Bun por compatibilidad) | Node (Bun con matices) |
| Tamaño | Muy pequeño, sin dependencias | Pequeño, 28 dependencias directas | Medio, 15 dependencias directas | Grande (varios paquetes @nestjs/*) |
| TypeScript | Nativo, con inferencia y RPC | Tipos aparte (@types/express) | Nativo, con proveedores de tipos | Nativo, basado en decoradores |
| Validación | Zod, Valibot, Standard Schema | A tu elección | JSON Schema integrado | class-validator o Standard Schema |
| Ideal para | Edge, serverless, APIs ligeras, monorepos TS | Proyectos clásicos, ecosistema enorme | APIs de alto rendimiento en Node | Aplicaciones grandes con arquitectura |
La comparativa completa, con pruebas de rendimiento reales en el mismo servidor, está en su propio artículo (enlace al final).
Cuándo elegir Hono (y cuándo no)
Elige Hono si despliegas en Cloudflare Workers, Deno Deploy, Bun o funciones serverless; si quieres que tu código no dependa del runtime; si te gusta TypeScript y quieres tipos compartidos entre frontend y backend sin generar nada; o si buscas algo pequeño y rápido para una API o un servidor MCP.
Piénsatelo dos veces si tu proyecto depende de un montón de middleware del ecosistema de Express (Passport y compañía), si tu equipo necesita la estructura rígida de módulos e inyección de dependencias de NestJS, o si necesitas funciones muy específicas de Node a bajo nivel. En Node puro, Fastify sigue siendo una opción excelente con un ecosistema de plugins muy maduro.
En mi caso, para APIs nuevas pequeñas y medianas, Hono se ha convertido en la primera opción: se escribe rápido, se prueba sin levantar servidores, se despliega en Coolify con un Dockerfile de veinte líneas y, si algún día quiero moverlo a Workers, solo cambio el arranque.
Sigue leyendo
Este artículo forma parte de una serie sobre frameworks de backend en JavaScript y TypeScript:
- Fastify a fondo: el framework de Node centrado en el rendimiento y los esquemas.
- Express a fondo: el clásico, ahora en su versión 5.
- NestJS 12 a fondo: arquitectura, módulos e inyección de dependencias.
- Hono, Fastify, Express y NestJS: la comparativa: ventajas de cada uno y pruebas de rendimiento reales.