Por anos, escolher um framework web em JavaScript significava aceitar um trade-off: Express era universal mas lento e preso ao Node, Fastify era rápido mas só Node, Next.js era completo mas pesado. Quando chegaram os runtimes edge - Cloudflare Workers, Deno Deploy, Bun, Vercel Edge - esses frameworks mostraram seus limites: dependências incompatíveis, bundles enormes, APIs amarradas a req/res do Node.
Hono (japonês para "chama" 🔥) é a resposta moderna. Um framework de menos de 14KB, totalmente baseado em Web Standards (Request, Response, fetch), que roda onde houver um runtime JavaScript. O mesmo código é deployado em Cloudflare Workers, Bun, Deno, Node.js, Vercel, Netlify e AWS Lambda - sem mudanças.
Por que Hono
Hono faz três coisas melhor do que qualquer outro:
- Performance. O
RegExpRoutercompila todos os padrões de rota em uma única regex, evitando os loops lineares dos routers tradicionais. Benchmarks superam 400.000 ops/s, colocando Hono entre os routers mais rápidos do ecossistema JavaScript. - Portabilidade. Web Standards significa zero dependências do Node. O mesmo
app.fetché exportado como default num Cloudflare Worker, passado paraBun.serve, montado num servidor Deno ou adaptado com@hono/node-server. - DX TypeScript-first. Path parameters tipados como literais, cliente RPC type-safe end-to-end, validadores que inferem tipos de entrada e saída. O autocomplete é quase telepático.
Começar
O caminho mais rápido é o starter oficial, que monta o projeto para o runtime escolhido.
npm create hono@latest my-api cd my-api npm install npm run dev
O starter pergunta qual template usar: cloudflare-workers, bun, deno, nodejs, vercel, aws-lambda, nextjs e mais. Ele escolhe configs e scripts de deploy adequados. Para experimentar rapidamente, também dá para começar com um único arquivo:
// src/index.ts import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Hello Hono!')) export default app
Em Cloudflare Workers isso basta. Em Bun: Bun.serve({ fetch: app.fetch, port: 3000 }). Em Node: serve({ fetch: app.fetch }) de @hono/node-server. A superfície é idêntica.
Routing
As rotas são declaradas com métodos de verbo HTTP e suportam parâmetros, wildcards e regex.
import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Home')) app.get('/posts/:id', (c) => { const id = c.req.param('id') // string, tipado return c.json({ id }) }) app.get('/posts/:id/comments/:commentId', (c) => { const { id, commentId } = c.req.param() return c.json({ id, commentId }) }) app.get('/files/*', (c) => c.text('Wildcard')) app.post('/posts', async (c) => { const body = await c.req.json() return c.json({ created: body }, 201) })
Os parâmetros são inferidos como tipos literais: TypeScript sabe que c.req.param('id') retorna string apenas se você declarou :id no padrão. Errar o nome é erro de compilação.
Agrupar rotas
app.route() permite compor sub-aplicações como módulos, cada uma com seu prefixo.
// routes/posts.ts import { Hono } from 'hono' const posts = new Hono() posts.get('/', (c) => c.json({ posts: [] })) posts.get('/:id', (c) => c.json({ id: c.req.param('id') })) export default posts // src/index.ts import { Hono } from 'hono' import posts from './routes/posts' const app = new Hono() app.route('/posts', posts)
Rotas aninhadas herdam o base path e o tipo do app raiz, então o cliente RPC enxerga toda a estrutura.
O objeto Context
Cada handler recebe um c - o Context para a requisição atual. É a única API que você precisa aprender para ler entradas e produzir saídas.
app.post('/echo', async (c) => { // Leitura const userAgent = c.req.header('User-Agent') const page = c.req.query('page') const body = await c.req.json() const env = c.env // bindings (KV, D1, secrets no Cloudflare) // Variáveis compartilhadas entre middleware e handler c.set('requestId', crypto.randomUUID()) const id = c.get('requestId') // Resposta c.header('X-Request-Id', id) c.status(200) return c.json({ userAgent, page, body, id }) })
Os métodos de resposta principais são c.text, c.json, c.html, c.body (raw) e c.redirect. Todos aceitam um status code como segundo argumento.
Middleware: o modelo cebola
Middleware são funções (c, next) => ... que podem rodar código antes e depois do handler. Compostos formam o clássico modelo cebola: o primeiro middleware registrado é o primeiro a começar e o último a terminar.
import { Hono } from 'hono' import { logger } from 'hono/logger' import { cors } from 'hono/cors' import { secureHeaders } from 'hono/secure-headers' const app = new Hono() app.use('*', logger()) app.use('*', secureHeaders()) app.use('/api/*', cors({ origin: 'https://spinny.dev' })) app.use('*', async (c, next) => { const start = performance.now() await next() c.header('X-Response-Time', `${performance.now() - start}ms`) })
await next() cede o controle ao próximo middleware. Tudo depois disso roda na fase de resposta, depois que o handler produziu um resultado. Retornar uma Response antes de next() interrompe a cadeia.
Middleware integrados
Hono entrega um conjunto rico de middleware production-ready, importáveis de hono/...:
| Middleware | Para que serve |
|---|---|
logger | Logs estruturados de método, path, status, duração |
cors | CORS configurável por origin, métodos, headers |
csrf | Proteção CSRF baseada em origin |
secureHeaders | Define CSP, HSTS, X-Frame-Options |
bearerAuth / basicAuth | Auth Bearer/Basic out of the box |
jwt | Verifica e assina JWT com jose |
etag | Gera ETag e trata 304 |
cache | Cache via Web Cache API |
compress | gzip/deflate na resposta |
bodyLimit | Rejeita bodies acima de um limite |
timing | Header Server-Timing para profiling |
Middleware custom type-safe
Para estender o Context com variáveis tipadas, use createMiddleware:
import { createMiddleware } from 'hono/factory' type AuthVars = { userId: string; role: 'user' | 'admin' } export const requireAuth = createMiddleware<{ Variables: AuthVars }>( async (c, next) => { const token = c.req.header('Authorization')?.replace('Bearer ', '') if (!token) return c.json({ error: 'Unauthorized' }, 401) const payload = await verifyJwt(token) c.set('userId', payload.sub) c.set('role', payload.role) await next() } ) // Uso app.get('/me', requireAuth, (c) => { const userId = c.var.userId // string, tipado return c.json({ userId }) })
Após o middleware, c.var.userId está tipado sem nenhum cast. Isso se propaga por toda a cadeia.
Validação com Zod
@hono/zod-validator integra Zod ao ciclo da requisição. Você define um schema, aplica à rota e obtém entradas já validadas e tipadas.
import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' const createPost = z.object({ title: z.string().min(1).max(200), body: z.string().min(1), tags: z.array(z.string()).default([]), }) app.post( '/posts', zValidator('json', createPost), (c) => { const data = c.req.valid('json') // tipado conforme o schema return c.json({ ok: true, post: data }, 201) } )
Se o body não validar, Hono responde 400 com o erro do Zod antes mesmo de chamar o handler. Você também pode validar query, param, header, cookie e form.
RPC: cliente type-safe end-to-end
A feature que realmente diferencia o Hono é o modo RPC. Você exporta o tipo do seu app do servidor, o cliente hc o importa e você obtém autocomplete completo - paths, query, body, headers e response - sem codegen ou OpenAPI.
// server.ts import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' const app = new Hono() .get('/posts/:id', (c) => c.json({ id: c.req.param('id'), title: 'Hello' }) ) .post( '/posts', zValidator('json', z.object({ title: z.string(), body: z.string() })), (c) => c.json({ ok: true }, 201) ) export type AppType = typeof app export default app
// client.ts import { hc } from 'hono/client' import type { AppType } from './server' const client = hc<AppType>('https://api.spinny.dev') const res = await client.posts[':id'].$get({ param: { id: '42' } }) if (res.ok) { const data = await res.json() // { id: string, title: string } console.log(data.title) } const created = await client.posts.$post({ json: { title: 'Olá', body: 'Hono é uma chama' }, // validado })
Renomeie uma rota no servidor e o TypeScript do cliente quebra imediatamente em CI. Mesma vantagem do tRPC, mas sobre HTTP padrão, sem middleware específico e com bundle mínimo.
Status codes discriminantes
Se você retorna status diferentes, o cliente os discrimina automaticamente.
.get('/posts/:id', (c) => { const post = findPost(c.req.param('id')) if (!post) return c.json({ error: 'not found' }, 404) return c.json({ post }, 200) })
const res = await client.posts[':id'].$get({ param: { id } }) if (res.status === 404) { const { error } = await res.json() // { error: string } } if (res.status === 200) { const { post } = await res.json() // { post: Post } }
Routers e performance
Hono oferece cinco routers com trade-offs diferentes. O default é o SmartRouter, que ao iniciar mede qual router serve melhor suas rotas e fixa nele.
| Router | Forças | Quando usar |
|---|---|---|
RegExpRouter | Velocidade máxima, regex compilada | Default para a maioria das APIs |
TrieRouter | Suporta todos os padrões | Padrões complexos não cobertos pelo RegExp |
SmartRouter | Escolhe o melhor automaticamente | Default recomendado |
LinearRouter | Registro ultra rápido | Workers one-shot, cold start crítico |
PatternRouter | Bundle mínimo (<15KB) | Restrições extremas de tamanho |
Para workers stateless com cold starts frequentes, LinearRouter evita o custo de compilação inicial e é 33x mais rápido que find-my-way quando se mede registro mais matching.
import { Hono } from 'hono' import { LinearRouter } from 'hono/router/linear-router' const app = new Hono({ router: new LinearRouter() })
Deploy multi-runtime
O mesmo export default app muda de destino apenas trocando o arquivo de entrada.
Cloudflare Workers
// src/index.ts import { Hono } from 'hono' type Bindings = { MY_KV: KVNamespace; DB: D1Database } const app = new Hono<{ Bindings: Bindings }>() app.get('/cache/:key', async (c) => { const value = await c.env.MY_KV.get(c.req.param('key')) return c.json({ value }) }) export default app
Deploy: npx wrangler deploy. Bindings KV/D1/R2/Queues são nativos.
Bun
import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Bun + Hono')) Bun.serve({ fetch: app.fetch, port: 3000 })
Node.js
import { serve } from '@hono/node-server' import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Node + Hono')) serve({ fetch: app.fetch, port: 3000 })
Deno
import { Hono } from 'jsr:@hono/hono' const app = new Hono() app.get('/', (c) => c.text('Deno + Hono')) Deno.serve(app.fetch)
Vercel
// api/[[...route]].ts import { Hono } from 'hono' import { handle } from 'hono/vercel' const app = new Hono().basePath('/api') app.get('/hello', (c) => c.json({ msg: 'Hello from Vercel' })) export const GET = handle(app) export const POST = handle(app)
Mesmo código de negócio, mesmo routing, mesmo middleware. Só o adaptador muda.
Exemplo prático: API REST com auth e DB
Juntando as peças. Uma API de blog em Cloudflare Workers + D1, com auth JWT, validação e RPC.
import { Hono } from 'hono' import { jwt } from 'hono/jwt' import { logger } from 'hono/logger' import { cors } from 'hono/cors' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' type Bindings = { DB: D1Database; JWT_SECRET: string } type Variables = { jwtPayload: { sub: string } } const app = new Hono<{ Bindings: Bindings; Variables: Variables }>() app.use('*', logger()) app.use('/api/*', cors({ origin: 'https://spinny.dev', credentials: true })) const auth = (c: any, next: any) => jwt({ secret: c.env.JWT_SECRET })(c, next) const api = app.basePath('/api') api.get('/posts', async (c) => { const { results } = await c.env.DB .prepare('SELECT id, title, created_at FROM posts ORDER BY created_at DESC LIMIT 50') .all() return c.json({ posts: results }) }) api.post( '/posts', auth, zValidator('json', z.object({ title: z.string().min(1).max(200), body: z.string().min(1), })), async (c) => { const { title, body } = c.req.valid('json') const userId = c.var.jwtPayload.sub const result = await c.env.DB .prepare('INSERT INTO posts (title, body, author_id) VALUES (?, ?, ?) RETURNING id') .bind(title, body, userId) .first<{ id: number }>() return c.json({ id: result?.id }, 201) } ) api.onError((err, c) => { console.error(err) return c.json({ error: 'Internal error' }, 500) }) export type AppType = typeof api export default app
Um cliente React consome com type safety total:
import { hc } from 'hono/client' import type { AppType } from '../api/src/index' const api = hc<AppType>(import.meta.env.VITE_API_URL) const res = await api.posts.$post({ json: { title: 'Hono em 2026', body: '...' }, }, { headers: { Authorization: `Bearer ${token}` }, })
Testes
app.request() permite testar rotas sem subir um servidor HTTP. É o mesmo path que rodaria em produção, executado em memória.
import { describe, it, expect } from 'vitest' import app from '../src/index' describe('GET /api/posts', () => { it('retorna a lista de posts', async () => { const res = await app.request('/api/posts') expect(res.status).toBe(200) const body = await res.json() expect(body.posts).toBeInstanceOf(Array) }) })
Em Cloudflare Workers, @cloudflare/vitest-pool-workers roda os mesmos testes dentro de um Worker real com bindings mock - máximo realismo, zero deploy.
Melhores práticas
1. Encadear definições de rota
const app = new Hono() .get('/posts', handler1) .post('/posts', handler2) .get('/posts/:id', handler3)
O chaining mantém o tipo do app atualizado a cada rota, essencial para o cliente RPC. Defini-las separadas com app.get(...) em linhas distintas quebra a inferência.
2. Exporte o tipo, não a implementação
O cliente deve importar AppType, não o app. import type garante que o build do frontend não inclua código de backend.
3. Um router por domínio
Um sub-app para posts, um para users, um para webhooks. Componha com app.route() e cada um possui seus middleware. A estrutura escala sem mega-arquivos.
4. Validação na borda, sempre
Toda entrada externa (body, query, header) deve passar por zValidator. Não confie em dados a jusante: até um cast TypeScript sem validação runtime é um bug à espera.
5. Apoie-se em bindings, não em clientes globais
Em Cloudflare, acesse KV/D1/R2 via c.env. Sem singletons globais, sem conexões persistindo entre Workers. O modelo stateless é uma feature, não uma limitação.
6. Meça antes de otimizar o router
O SmartRouter padrão dá conta em 95% dos casos. Troque o router só após profilar e ver um gargalo real.
Conclusão
Hono virou em 2026 o padrão de fato para construir APIs prontas para o edge em TypeScript. A combinação de Web Standards, performance, type safety e portabilidade resolve exatamente os problemas que travavam os frameworks tradicionais: lock-in ao runtime, bundles pesados, type system frágil entre cliente e servidor.
Você vai usá-lo para microsserviços em Cloudflare Workers, para substituir Express num monolito Node, para uma function na Vercel ou para uma API em Bun. O mesmo conhecimento se transfere para todo lugar - e isso é o que torna ele um investimento sólido para os próximos anos.
Checklist para começar:
npm create hono@lateste escolha seu template de runtime- Defina rotas com chaining (
.get(...).post(...))- Adicione
logger,cors,secureHeaderscomo middleware globais- Valide toda entrada com
@hono/zod-validator- Exporte
AppTypee consuma a API com o clientehctype-safe- Escreva testes com
app.request()- sem servidor HTTP- Deploy com
wrangler deploy(CF),vercel deployou o bundler do seu runtime