Годами выбор JavaScript веб-фреймворка означал принятие компромисса: Express универсален, но медленный и привязан к Node, Fastify быстр, но только для Node, Next.js полнофункционален, но тяжёлый. С появлением edge-рантаймов — Cloudflare Workers, Deno Deploy, Bun, Vercel Edge — эти фреймворки показали свои пределы: несовместимые зависимости, огромные бандлы, API, привязанные к req/res Node.
Hono (с японского "пламя" 🔥) — современный ответ. Фреймворк весом менее 14KB, полностью построенный на Web Standards (Request, Response, fetch), работающий везде, где есть JavaScript-рантайм. Один и тот же код деплоится на Cloudflare Workers, Bun, Deno, Node.js, Vercel, Netlify и AWS Lambda — без изменений.
Почему Hono
Hono делает три вещи лучше всех:
- Производительность.
RegExpRouterкомпилирует все паттерны маршрутов в одно регулярное выражение, избегая линейных циклов традиционных маршрутизаторов. Бенчмарки превышают 400 000 ops/s, ставя Hono в число самых быстрых маршрутизаторов JavaScript-экосистемы. - Портативность. Web Standards означает ноль зависимостей от Node. Один и тот же
app.fetchэкспортируется по умолчанию в Cloudflare Worker, передаётся вBun.serve, монтируется в Deno-сервер или адаптируется через@hono/node-server. - TypeScript-first DX. Параметры пути типизированы как литералы, типобезопасный RPC-клиент end-to-end, валидаторы, выводящие типы входа и выхода. Автодополнение почти телепатическое.
Начало работы
Самый быстрый путь — официальный стартер, который скаффолдит проект для выбранного рантайма.
npm create hono@latest my-api cd my-api npm install npm run dev
Стартер спрашивает, какой шаблон использовать: cloudflare-workers, bun, deno, nodejs, vercel, aws-lambda, nextjs и другие. Для быстрой пробы можно начать и с одного файла:
// src/index.ts import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Hello Hono!')) export default app
На Cloudflare Workers этого достаточно. На Bun: Bun.serve({ fetch: app.fetch, port: 3000 }). На Node: serve({ fetch: app.fetch }) из @hono/node-server. Поверхность идентична.
Маршрутизация
Маршруты объявляются методами HTTP-глаголов и поддерживают параметры, wildcard и 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, типизировано 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) })
Параметры выводятся как литеральные типы: TypeScript знает, что c.req.param('id') возвращает string только если вы объявили :id в паттерне. Опечатка — ошибка времени компиляции.
Группировка маршрутов
app.route() позволяет компоновать суб-приложения как модули, каждое со своим префиксом.
// 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)
Вложенные маршруты наследуют base path и тип корневого приложения, поэтому RPC-клиент видит всю структуру.
Объект Context
Каждый обработчик получает c — Context для текущего запроса. Это единственный API, который нужно изучить, чтобы читать вход и производить выход.
app.post('/echo', async (c) => { // Чтение 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, секреты на Cloudflare) // Переменные, разделяемые между middleware и обработчиком c.set('requestId', crypto.randomUUID()) const id = c.get('requestId') // Ответ c.header('X-Request-Id', id) c.status(200) return c.json({ userAgent, page, body, id }) })
Основные методы ответа: c.text, c.json, c.html, c.body (raw), c.redirect. Все принимают код состояния вторым аргументом.
Middleware: луковичная модель
Middleware — функции (c, next) => ..., которые могут выполнять код до и после обработчика. Скомпонованные, они образуют классическую луковичную модель: первый зарегистрированный middleware первым стартует и последним заканчивает.
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() передаёт управление следующему middleware. Всё, что после, выполняется в фазе ответа после того, как обработчик произвёл результат. Возврат Response до next() короткозамыкает цепочку.
Встроенный middleware
| Middleware | Назначение |
|---|---|
logger | Структурированные логи метода, пути, статуса, длительности |
cors | CORS, настраиваемый по origin, методам, заголовкам |
csrf | Защита CSRF на основе origin |
secureHeaders | Устанавливает CSP, HSTS, X-Frame-Options |
bearerAuth / basicAuth | Готовая Bearer/Basic-аутентификация |
jwt | Проверка/подпись JWT через jose |
etag | Генерирует ETag и обрабатывает 304 |
cache | Кэш через Web Cache API |
compress | gzip/deflate ответа |
bodyLimit | Отклоняет тела выше порога |
timing | Заголовок Server-Timing для профилирования |
Типобезопасный custom middleware
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() } ) app.get('/me', requireAuth, (c) => { const userId = c.var.userId // string, типизировано return c.json({ userId }) })
Валидация с Zod
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') return c.json({ ok: true, post: data }, 201) } )
Если тело не проходит валидацию, Hono отвечает 400 с ошибкой Zod ещё до вызова обработчика. Можно валидировать также query, param, header, cookie и form.
RPC: типобезопасный клиент end-to-end
// 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() console.log(data.title) } const created = await client.posts.$post({ json: { title: 'Привет', body: 'Hono — это пламя' }, })
Переименуйте маршрут на сервере, и TypeScript клиента сразу же сломается в CI. То же преимущество, что у tRPC, но поверх стандартного HTTP, без специфичного middleware и с крошечным бандлом.
Дискриминация по статус-коду
.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 } }
Маршрутизаторы и производительность
| Маршрутизатор | Сильные стороны | Когда использовать |
|---|---|---|
RegExpRouter | Максимальная скорость, скомпилированный regex | По умолчанию для большинства API |
TrieRouter | Поддерживает любые паттерны | Сложные паттерны, не покрытые RegExp |
SmartRouter | Выбирает лучший автоматически | Рекомендуемый по умолчанию |
LinearRouter | Сверхбыстрая регистрация | One-shot воркеры, критичный cold start |
PatternRouter | Минимальный бандл (<15KB) | Жёсткие ограничения по размеру |
import { Hono } from 'hono' import { LinearRouter } from 'hono/router/linear-router' const app = new Hono({ router: new LinearRouter() })
Мульти-рантайм деплой
Cloudflare Workers
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
Деплой: npx wrangler deploy. KV/D1/R2/Queues bindings нативны.
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)
Тот же бизнес-код, та же маршрутизация, тот же middleware. Меняется только адаптер.
Реальный пример: REST API с auth и DB
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
React-клиент потребляет с полной типобезопасностью:
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 в 2026', body: '...' }, }, { headers: { Authorization: `Bearer ${token}` }, })
Тестирование
import { describe, it, expect } from 'vitest' import app from '../src/index' describe('GET /api/posts', () => { it('возвращает список постов', async () => { const res = await app.request('/api/posts') expect(res.status).toBe(200) const body = await res.json() expect(body.posts).toBeInstanceOf(Array) }) })
app.request() позволяет тестировать маршруты без поднятия HTTP-сервера.
Лучшие практики
1. Цепочка определений маршрутов
const app = new Hono() .get('/posts', handler1) .post('/posts', handler2) .get('/posts/:id', handler3)
Цепочка поддерживает тип app актуальным с каждым маршрутом — критично для RPC-клиента.
2. Экспортируй тип, не реализацию
Клиент должен импортировать AppType, а не приложение. import type гарантирует, что фронтенд-сборка не включит код бэкенда.
3. Один маршрутизатор на домен
Под-приложение для posts, для users, для webhooks. Компонуй через app.route().
4. Валидация на границе, всегда
Каждый внешний вход (body, query, header) должен пройти через zValidator.
5. Опирайся на bindings, а не глобальные клиенты
На Cloudflare доступ к KV/D1/R2 через c.env.
6. Замеряй до оптимизации маршрутизатора
Дефолтный SmartRouter подходит для 95% случаев.
Заключение
Hono стал в 2026 году де-факто стандартом для построения edge-готовых API на TypeScript. Сочетание Web Standards, производительности, типобезопасности и портативности решает именно те проблемы, которые сдерживали традиционные фреймворки.
Чек-лист для старта:
npm create hono@latestи выберите шаблон рантайма- Определите маршруты цепочкой (
.get(...).post(...))- Добавьте
logger,cors,secureHeadersкак глобальный middleware- Валидируйте каждый вход с
@hono/zod-validator- Экспортируйте
AppTypeи используйте API через типобезопасныйhc-клиент- Пишите тесты с
app.request()— без HTTP-сервера- Деплойте через
wrangler deploy(CF),vercel deployили бандлер вашего рантайма