> ## Content Index
> Fetch the complete content index at: https://blog.devutilites.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Hono a fondo: el framework web ultrarrápido que funciona en cualquier runtime
- URL: https://blog.devutilites.com/hono-a-fondo/
- Published: 2026-09-29T18:33:00.000Z
- Updated: 2026-09-29T18:32:59.000Z
- Description: 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.
- Author: Luis Collantes
- Tags: Backend

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.

🧪

****Todo el código de este artículo se ha ejecutado de verdad** el 29 de septiembre de 2026 con ****Hono 4.13.11**, ****@hono/node-server 2.1.3**, ****@hono/zod-validator 0.9.1**, ****Zod 4.6**, ****TypeScript 7.0**, ****Vitest 4.1** y ****Node.js 20.20**. Los ejemplos de runtimes se han probado en contenedores con ****Bun 1.4.2**, ****Deno 2.9.7** y ****Wrangler 4.143** (Cloudflare Workers en local).

## 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](https://hono.dev/?ref=blog.devutilites.com) · [guía](https://hono.dev/docs/?ref=blog.devutilites.com) · [referencia de la API](https://hono.dev/docs/api/hono?ref=blog.devutilites.com) |
| **Paquete en npm**              | [npmjs.com/package/hono](https://www.npmjs.com/package/hono?ref=blog.devutilites.com)                                                                                                              |
| **Código fuente**               | [github.com/honojs/hono](https://github.com/honojs/hono?ref=blog.devutilites.com) · [versiones y notas](https://github.com/honojs/hono/releases?ref=blog.devutilites.com)                          |
| **Adaptador para Node.js**      | [@hono/node-server](https://www.npmjs.com/package/@hono/node-server?ref=blog.devutilites.com) · [GitHub](https://github.com/honojs/node-server?ref=blog.devutilites.com)                           |
| **Middleware de terceros**      | [github.com/honojs/middleware](https://github.com/honojs/middleware?ref=blog.devutilites.com) (zod-validator, OpenAPI, Sentry, Prometheus, etc.)                                                   |
| **Deno (JSR)**                  | [jsr.io/@hono/hono](https://jsr.io/@hono/hono?ref=blog.devutilites.com)                                                                                                                            |

### 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:

```bash
# 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:3000
```

Las 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:

```json
{
  "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

```bash
# 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
```

📦

****Novedad de septiembre de 2026:** desde la versión 4.13.10, los adaptadores de cada plataforma se publican como paquetes propios: `@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`:

```typescript
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}`)
})
```

```bash
$ 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.

![Esquema de Hono: los runtimes (Node, Bun, Deno, Workers, Lambda) entregan un Request estándar a app.fetch, que pasa por router, contexto, middleware y manejo de errores y devuelve un Response](https://blog.devutilites.com/content/images/2026/09/hono-arquitectura.png)

Hono solo habla Request y Response; cada runtime pone su adaptador. Diagrama: devutilites.

### 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`/`Response` de forma nativa. Le pasan la petición directamente a `app.fetch` y ya está.
- **Node.js** no: su servidor HTTP trabaja con `IncomingMessage` y `ServerResponse`. 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 en `Request` y el `Response` en 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:

```typescript
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`)
}
```

```text
$ 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:

```typescript
// 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   |

⚡

****Desde Hono 4.13, RegExpRouter avisa al registrar las rutas.** Si una combinación de rutas no se puede representar en una sola expresión regular, lanza `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), y `c.req.raw` para el `Request` original.
- `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()` y `c.status()`: modifican la respuesta que se está construyendo.
- `c.set()` y `c.get()` (o `c.var`): guardan datos durante la petición, por ejemplo el usuario autenticado. Se tipan con el genérico `Variables`.
- `c.env`: las variables de entorno y los *bindings* de la plataforma (KV, D1, R2 en Cloudflare). En Node, lo habitual es seguir usando `process.env`.
- `c.executionCtx`: en Workers, permite `waitUntil()` 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.

![Modelo de cebolla del middleware de Hono: requestId, logger, cronómetro y secureHeaders envuelven al handler; se ejecutan a la ida en orden y a la vuelta en orden inverso](https://blog.devutilites.com/content/images/2026/09/hono-cebolla.png)

Lo que va antes de await next() se ejecuta a la ida; lo de después, a la vuelta. Diagrama: devutilites.

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`:

```typescript
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:

```bash
$ 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 que `c.req.param()` devuelve `{ postId: string; comentarioId: string }`. Si escribes mal el nombre, no compila.
- **Los parámetros siempre son texto.** `/usuarios/7` te da `"7"`, no `7`. 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 propio `new Hono()` y la aplicación principal los monta con un prefijo.

🆕

****Método QUERY (Hono 4.13).** El nuevo método HTTP QUERY (RFC 10008) es como un GET que admite cuerpo: seguro, idempotente y cacheable, ideal para búsquedas con filtros complejos que no caben en la URL. Hono lo soporta con `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:

```typescript
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
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 cronometro
```

Y 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:

```bash
# 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 2ms
```

### Los 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. |

🚫

****El error más frecuente con JWT:** desde hace varias versiones el middleware `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`:

```bash
npm install @hono/zod-validator zod
```

Puedes 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:

```bash
$ 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"]}}
```

💡

****Un detalle de tipos que me costó un rato:** si pasas a `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:

```typescript
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 })
```

```bash
$ 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`:

```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`:

```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 })
```

```bash
$ 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: &lt;script&gt;alert(1)&lt;/script&gt;</p>

$ curl "localhost:3000/plantilla?nombre=%3Cb%3EAna%3C/b%3E"
<!doctype html>
    <p>Hola, &lt;b&gt;Ana&lt;/b&gt;</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**.

🔒

****Actualiza si usas JSX.** Durante 2026 se han corregido varios fallos de seguridad en `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:

```typescript
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 })
```

```bash
$ 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 ejemplo
```

En el navegador, los eventos se reciben con muy poco código:

```javascript
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())
```

⚠️

****Detrás de un proxy, desactiva el buffer.** Si Nginx, Traefik o Cloudflare acumulan la respuesta antes de enviarla, el streaming deja de verse en directo. Hono ya envía `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:

```text
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 Vitest
```

### Las rutas: src/api/tareas.ts

```typescript
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.

🐛

****Un fallo real que encontré probando este ejemplo.** Mi primera versión definía el esquema del PATCH como `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

```typescript
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 app
```

### El arranque: src/api/servidor.ts

```typescript
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

```bash
$ 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.

```typescript
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)
```

```text
$ 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/1
```

Si 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 por `res.status` o `res.ok`.
- En APIs muy grandes, el editor puede ir lento calculando tipos. La documentación recomienda compilar el tipo de antemano con `tsc` y usar el `.d.ts` resultante.

## 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:

```bash
npm install -D vitest
```

```typescript
import { 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' })
  })
})
```

```text
$ 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

```typescript
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 })
```

```bash
$ 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:

```typescript
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,
}
```

```bash
$ 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ático
```

### Deno

Deno puede importar Hono desde npm o desde JSR, y `Deno.serve()` acepta `app.fetch` tal cual:

```typescript
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)
```

```bash
$ 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.7
```

### Cloudflare 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`:

```typescript
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
```

```json
{
  "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" }]
}
```

```bash
$ 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 Cloudflare
```

En 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.

💡

****Usa una** **`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](https://blog.devutilites.com/coolify-paas-autoalojado/) o cualquier otra plataforma basada en Docker, estos son los scripts del `package.json`:

```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:

```dockerfile
# --- 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"]
```

```bash
$ 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.57GiB
```

La 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)` sin `return` produce 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 de `app.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 usas `c.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 app` no incluye esas rutas y el cliente `hc` no 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`, `path` o `process.env` no existen (o existen parcialmente con `nodejs_compat`). Si quieres portabilidad real, usa `c.env` y APIs web.
- **`serveStatic` importado del sitio equivocado.** Cada runtime tiene el suyo: `@hono/node-server/serve-static` en Node, `@hono/bun` en Bun, `@hono/deno` en Deno.
- **Middleware que no llama a `next()`.** Si un middleware ni devuelve una respuesta ni llama a `await 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`…). Un `npm outdated` de 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](https://blog.devutilites.com/fastify-a-fondo/): el framework de Node centrado en el rendimiento y los esquemas.
- [Express a fondo](https://blog.devutilites.com/express-a-fondo/): el clásico, ahora en su versión 5.
- [NestJS 12 a fondo](https://blog.devutilites.com/nestjs-12-a-fondo/): arquitectura, módulos e inyección de dependencias.
- [Hono, Fastify, Express y NestJS: la comparativa](https://blog.devutilites.com/hono-fastify-express-nestjs-comparativa/): ventajas de cada uno y pruebas de rendimiento reales.