NAME
hono-framework-guide — Hono : le framework web ultra-rapide bâti sur les Web Standards
SYNOPSIS
cat hono-framework-guide.md
DESCRIPTION
Pendant des années, choisir un framework web JavaScript impliquait un compromis : Express était universel mais lent et lié à Node, Fastify était rapide mais réservé à Node, Next.js était complet mais lourd. Quand sont apparus les runtimes edge - Cloudflare Workers, Deno Deploy, Bun, Vercel Edge - ces frameworks ont montré leurs limites : dépendances incompatibles, bundles énormes, APIs liées aux req/res de Node.
Hono (japonais pour "flamme" 🔥) est la réponse moderne. Un framework de moins de 14 Ko, entièrement bâti sur les Web Standards (Request, Response, fetch), qui tourne partout où existe un runtime JavaScript. Le même code se déploie sur Cloudflare Workers, Bun, Deno, Node.js, Vercel, Netlify et AWS Lambda - sans modification.
Pourquoi Hono
Hono fait trois choses mieux que tout le monde :
- Performance. Le
RegExpRoutercompile tous les patterns de route en une seule regex, évitant les boucles linéaires des routeurs traditionnels. Les benchmarks dépassent 400 000 ops/s, plaçant Hono parmi les routeurs les plus rapides de l'écosystème JavaScript. - Portabilité. Web Standards signifie zéro dépendance à Node. Le même
app.fetchest exporté par défaut dans un Cloudflare Worker, passé àBun.serve, monté dans un serveur Deno ou adapté avec@hono/node-server. - DX TypeScript-first. Paramètres de chemin typés en literal, client RPC type-safe de bout en bout, validateurs qui infèrent les types d'entrée et de sortie. L'autocomplétion frôle la télépathie.
Démarrer
Le moyen le plus rapide est le starter officiel, qui scaffolde le projet pour le runtime choisi.
npm create hono@latest my-api cd my-api npm install npm run dev
Le starter demande quel template utiliser : cloudflare-workers, bun, deno, nodejs, vercel, aws-lambda, nextjs, et plus. Il choisit les bonnes configs et scripts de déploiement. Pour tester à la volée, on peut aussi partir d'un seul fichier :
// src/index.ts import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Hello Hono!')) export default app
Sur Cloudflare Workers, ça suffit. Sur Bun : Bun.serve({ fetch: app.fetch, port: 3000 }). Sur Node : serve({ fetch: app.fetch }) depuis @hono/node-server. La surface est identique.
Routing
Les routes se déclarent avec les méthodes des verbes HTTP et supportent paramètres, wildcards et 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, typé 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) })
Les paramètres sont inférés en types literal : TypeScript sait que c.req.param('id') retourne un string uniquement si tu as déclaré :id dans le pattern. Une faute de frappe est une erreur de compilation.
Grouper les routes
app.route() permet de composer des sous-applications comme des modules, chacune avec son préfixe.
// 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)
Les routes imbriquées héritent du base path et du type de l'app racine, donc le client RPC voit toute la structure.
L'objet Context
Chaque handler reçoit un c - le Context de la requête courante. C'est la seule API à apprendre pour lire les entrées et produire les sorties.
app.post('/echo', async (c) => { // Lecture 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 sur Cloudflare) // Variables partagées entre middleware et handler c.set('requestId', crypto.randomUUID()) const id = c.get('requestId') // Réponse c.header('X-Request-Id', id) c.status(200) return c.json({ userAgent, page, body, id }) })
Les méthodes de réponse principales sont c.text, c.json, c.html, c.body (raw) et c.redirect. Toutes acceptent un code de statut en deuxième argument.
Middleware : le modèle en oignon
Les middleware sont des fonctions (c, next) => ... qui peuvent exécuter du code avant et après le handler. Composés, ils forment le classique modèle en oignon : le premier middleware enregistré est le premier à démarrer et le dernier à terminer.
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() cède le contrôle au middleware suivant. Tout ce qui suit s'exécute en phase de réponse, après que le handler a produit un résultat. Retourner une Response avant next() court-circuite la chaîne.
Middleware intégrés
Hono livre un riche ensemble de middleware production-ready, importables depuis hono/... :
| Middleware | Rôle |
|---|---|
logger | Logs structurés méthode, chemin, statut, durée |
cors | CORS configurable par origin, méthodes, headers |
csrf | Protection CSRF basée sur l'origin |
secureHeaders | Définit CSP, HSTS, X-Frame-Options |
bearerAuth / basicAuth | Auth Bearer/Basic prête à l'emploi |
jwt | Vérification/signature JWT avec jose |
etag | Génère ETag et gère le 304 |
cache | Cache via Web Cache API |
compress | gzip/deflate sur la réponse |
bodyLimit | Rejette les bodies au-dessus d'un seuil |
timing | Header Server-Timing pour le profiling |
Middleware custom type-safe
Pour étendre le Context avec des variables typées, utilise 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() } ) // Utilisation app.get('/me', requireAuth, (c) => { const userId = c.var.userId // string, typé return c.json({ userId }) })
En aval du middleware, c.var.userId est typé sans aucun cast. La propagation se fait dans toute la chaîne.
Validation avec Zod
@hono/zod-validator branche Zod dans le cycle de la requête. Tu définis un schéma, l'appliques à la route et obtiens des entrées déjà validées et typées.
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') // typé selon le schéma return c.json({ ok: true, post: data }, 201) } )
Si le body échoue à la validation, Hono répond 400 avec l'erreur Zod sans même appeler le handler. Tu peux aussi valider query, param, header, cookie et form.
RPC : client type-safe de bout en bout
La fonctionnalité qui distingue vraiment Hono est le mode RPC. Tu exportes le type de ton app depuis le serveur, le client hc l'importe et tu obtiens une autocomplétion complète - chemins, query, body, headers, réponse - sans codegen ni OpenAPI.
// server.ts import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' const app = new Hono() .get('/posts/:id', (c) => c.json({ id: c.req.param('id'), title: 'Hello' }) ) .post( '/posts', zValidator('json', z.object({ title: z.string(), body: z.string() })), (c) => c.json({ ok: true }, 201) ) export type AppType = typeof app export default app
// client.ts import { hc } from 'hono/client' import type { AppType } from './server' const client = hc<AppType>('https://api.spinny.dev') const res = await client.posts[':id'].$get({ param: { id: '42' } }) if (res.ok) { const data = await res.json() // { id: string, title: string } console.log(data.title) } const created = await client.posts.$post({ json: { title: 'Salut', body: 'Hono est une flamme' }, // validé })
Renomme une route côté serveur et le TypeScript du client casse immédiatement en CI. Même avantage que tRPC, mais sur HTTP standard, sans middleware spécifique et avec un bundle minuscule.
Discrimination par code de statut
Si tu retournes des statuts différents, le client les discrimine automatiquement.
.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 } }
Routeurs et performance
Hono propose cinq routeurs avec différents trade-offs. Le défaut est le SmartRouter, qui au démarrage mesure quel routeur sert le mieux tes routes et s'y fixe.
| Routeur | Points forts | Quand l'utiliser |
|---|---|---|
RegExpRouter | Vitesse maximale, regex compilée | Défaut pour la plupart des APIs |
TrieRouter | Supporte tous les patterns | Patterns complexes non gérés par RegExp |
SmartRouter | Choisit le meilleur automatiquement | Défaut recommandé |
LinearRouter | Enregistrement ultra-rapide | Workers one-shot, cold starts critiques |
PatternRouter | Bundle minimal (<15 Ko) | Contraintes de taille extrêmes |
Pour des workers stateless avec cold starts fréquents, LinearRouter évite le coût de compilation initial et est 33x plus rapide que find-my-way quand on mesure enregistrement plus matching.
import { Hono } from 'hono' import { LinearRouter } from 'hono/router/linear-router' const app = new Hono({ router: new LinearRouter() })
Déploiement multi-runtime
Le même export default app change de cible en ne touchant qu'au fichier d'entrée.
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
Déploiement : npx wrangler deploy. Bindings KV/D1/R2/Queues natifs.
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)
Même code métier, même routing, même middleware. Seul l'adaptateur change.
Exemple concret : API REST avec auth et BD
Mettons les pièces ensemble. Une API blog sur Cloudflare Workers + D1, avec auth JWT, validation et RPC.
import { Hono } from 'hono' import { jwt } from 'hono/jwt' import { logger } from 'hono/logger' import { cors } from 'hono/cors' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' type Bindings = { DB: D1Database; JWT_SECRET: string } type Variables = { jwtPayload: { sub: string } } const app = new Hono<{ Bindings: Bindings; Variables: Variables }>() app.use('*', logger()) app.use('/api/*', cors({ origin: 'https://spinny.dev', credentials: true })) const auth = (c: any, next: any) => jwt({ secret: c.env.JWT_SECRET })(c, next) const api = app.basePath('/api') api.get('/posts', async (c) => { const { results } = await c.env.DB .prepare('SELECT id, title, created_at FROM posts ORDER BY created_at DESC LIMIT 50') .all() return c.json({ posts: results }) }) api.post( '/posts', auth, zValidator('json', z.object({ title: z.string().min(1).max(200), body: z.string().min(1), })), async (c) => { const { title, body } = c.req.valid('json') const userId = c.var.jwtPayload.sub const result = await c.env.DB .prepare('INSERT INTO posts (title, body, author_id) VALUES (?, ?, ?) RETURNING id') .bind(title, body, userId) .first<{ id: number }>() return c.json({ id: result?.id }, 201) } ) api.onError((err, c) => { console.error(err) return c.json({ error: 'Internal error' }, 500) }) export type AppType = typeof api export default app
Un client React la consomme avec une type-safety complète :
import { hc } from 'hono/client' import type { AppType } from '../api/src/index' const api = hc<AppType>(import.meta.env.VITE_API_URL) const res = await api.posts.$post({ json: { title: 'Hono en 2026', body: '...' }, }, { headers: { Authorization: `Bearer ${token}` }, })
Tests
app.request() permet de tester les routes sans démarrer un serveur HTTP. C'est le même chemin qu'en production, exécuté en mémoire.
import { describe, it, expect } from 'vitest' import app from '../src/index' describe('GET /api/posts', () => { it('renvoie la liste des posts', async () => { const res = await app.request('/api/posts') expect(res.status).toBe(200) const body = await res.json() expect(body.posts).toBeInstanceOf(Array) }) })
Sur Cloudflare Workers, @cloudflare/vitest-pool-workers fait tourner les mêmes tests dans un vrai Worker avec des bindings mock - réalisme maximal, zéro déploiement.
Bonnes pratiques
1. Chaîner les définitions de route
const app = new Hono() .get('/posts', handler1) .post('/posts', handler2) .get('/posts/:id', handler3)
Le chaînage maintient le type de app à jour à chaque route, ce qui est essentiel pour le client RPC. Les définir séparément avec app.get(...) sur des lignes distinctes casse l'inférence.
2. Exporter le type, pas l'implémentation
Le client doit importer AppType, pas l'app. import type garantit que le build du frontend ne contient pas le code backend.
3. Un routeur par domaine
Une sous-app pour posts, une pour users, une pour webhooks. Compose-les avec app.route() et chacune possède ses middleware. La structure scale sans méga-fichiers.
4. Validation en bord, toujours
Toute entrée externe (body, query, header) doit passer par zValidator. Ne fais pas confiance aux données en aval : même un cast TypeScript sans validation runtime est un bug en attente.
5. Mise à profit des bindings, pas des clients globaux
Sur Cloudflare, accède à KV/D1/R2 via c.env. Pas de singletons globaux, pas de connexions persistant entre Workers. Le modèle stateless est une fonctionnalité, pas une limitation.
6. Mesurer avant d'optimiser le routeur
Le SmartRouter par défaut convient à 95 % des cas. Change de routeur seulement après avoir profilé et constaté un goulot d'étranglement réel.
Conclusion
Hono est devenu en 2026 le standard de facto pour construire des APIs prêtes pour le edge en TypeScript. La combinaison de Web Standards, performance, type safety et portabilité résout précisément les problèmes qui freinaient les frameworks traditionnels : lock-in au runtime, bundles lourds, type system fragile entre client et serveur.
Tu l'utiliseras pour des microservices sur Cloudflare Workers, pour remplacer Express dans un monolithe Node, pour une fonction sur Vercel ou pour une API en Bun. Le même savoir se transfère partout - et c'est ce qui en fait un investissement solide pour les années à venir.
Checklist pour démarrer :
npm create hono@latestet choisis ton template de runtime- Définis les routes avec le chaînage (
.get(...).post(...))- Ajoute
logger,cors,secureHeaderscomme middleware globaux- Valide chaque entrée avec
@hono/zod-validator- Exporte
AppTypeet consomme l'API avec le clienthctype-safe- Écris les tests avec
app.request()- sans serveur HTTP- Déploie avec
wrangler deploy(CF),vercel deployou le bundler de ton runtime
METADATA
- date: 2026-04-27
- reading: 13 min
- author: Filippo Spinella
- tags: Backend, TypeScript, Edge, Developer Tools