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