Construire une API REST en 2026 (informations vérifiées en avril 2026, susceptibles d’évoluer) ne se résume plus à choisir Express + Node. Le combo Bun + Hono offre une expérience moderne, type-safe, ultra-rapide et portable : la même API peut tourner sur Bun, Node.js, Deno, Cloudflare Workers, AWS Lambda, ou n’importe quel runtime JavaScript moderne. Avec quelques lignes de code, vous obtenez du routing avancé, de la validation Zod, du middleware modulaire et des performances qui dépassent largement Express. Voici le tutoriel complet, du hello world à la mise en production.
Ce tutoriel s’inscrit dans notre série Bun. Pour les bases (installation, philosophie, comparaison avec Node.js), lisez d’abord notre guide pratique Bun en production 2026.
Pourquoi Hono ?
Hono est un framework web ultra-léger (moins de 14 Ko gzippé) créé par Yusuke Wada. Il s’inspire d’Express en termes d’API mais est entièrement réécrit en TypeScript moderne avec une attention particulière à la performance et au type-safety. En 2026, Hono est devenu le choix par défaut pour les API JavaScript modernes, notamment pour ces raisons :
- Portable : votre code tourne identiquement sur Bun, Node.js, Deno, Cloudflare Workers, Vercel Edge, AWS Lambda. Pas de vendor lock-in.
- Type-safe : les routes, paramètres, body et réponses sont typés. Si vous changez un schéma, TypeScript signale toutes les utilisations affectées.
- Performance : sur Bun, Hono atteint 30 000-50 000 RPS sur du hardware modeste, soit 5 à 10x Express.
- Middleware riche : auth JWT, CORS, compression, rate limiting, logger structuré, cache, tous officiels et maintenus.
- Validation Zod intégrée via
@hono/zod-validator - RPC client : générer un client TypeScript type-safe à partir des routes server, sans codegen, en quelques lignes.
Prérequis
- Bun installé (voir guide Bun)
- Connaissance basique de TypeScript et des API REST
- Un éditeur avec support TypeScript (VS Code recommandé)
- Niveau attendu : intermédiaire
- Temps : 30 minutes pour le hello world, 2 heures pour une API CRUD complète avec auth
Étape 1 — Initialiser le projet
mkdir mon-api && cd mon-api
bun init -y
# Installer Hono et Zod
bun add hono zod @hono/zod-validator
bun add -D @types/bun
Le fichier tsconfig.json par défaut généré par Bun convient. Si vous voulez de la stricte ultra-stricte, ajoutez "strict": true, "noUncheckedIndexedAccess": true.
Étape 2 — Premier serveur Hono
// src/index.ts
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://app.exemple.sn", "http://localhost:3000"],
credentials: true,
}));
app.get("/", (c) => c.text("Hono API on Bun"));
app.get("/api/health", (c) => c.json({
status: "ok",
runtime: "bun",
time: new Date().toISOString(),
}));
export default {
port: 3000,
fetch: app.fetch,
};
Lancez avec bun run --hot src/index.ts. Le flag --hot active le hot reload : modifiez le code, le serveur redémarre instantanément, pas besoin de nodemon ou tsx.
Étape 3 — Routing CRUD avec validation
// src/routes/users.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
const users = new Hono();
const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().min(13).max(120),
});
const userIdParam = z.object({
id: z.string().regex(/^\d+$/).transform(Number),
});
// In-memory store (en prod : remplacer par Drizzle/Postgres)
let userStore: Array<{ id: number; name: string; email: string; age: number }> = [];
let nextId = 1;
users.get("/", (c) => c.json({ data: userStore, total: userStore.length }));
users.post("/", zValidator("json", createUserSchema), (c) => {
const body = c.req.valid("json");
const user = { id: nextId++, ...body };
userStore.push(user);
return c.json(user, 201);
});
users.get("/:id", zValidator("param", userIdParam), (c) => {
const { id } = c.req.valid("param");
const user = userStore.find((u) => u.id === id);
if (!user) return c.json({ error: "Not found" }, 404);
return c.json(user);
});
users.delete("/:id", zValidator("param", userIdParam), (c) => {
const { id } = c.req.valid("param");
const idx = userStore.findIndex((u) => u.id === id);
if (idx === -1) return c.json({ error: "Not found" }, 404);
userStore.splice(idx, 1);
return c.json({ deleted: true });
});
export default users;
Brancher dans index.ts :
import users from "./routes/users";
app.route("/api/users", users);
Testez avec curl :
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"name":"Aïssatou","email":"a@exemple.sn","age":28}'
Si vous envoyez un body invalide (email malformé par exemple), Hono renvoie automatiquement un 400 avec le détail Zod. Pas de code de validation à écrire.
Étape 4 — Authentification JWT
import { jwt, sign } from "hono/jwt";
const JWT_SECRET = process.env.JWT_SECRET ?? (() => {
throw new Error("JWT_SECRET manquant");
})();
// Route publique pour login
app.post("/api/auth/login", async (c) => {
const { email, password } = await c.req.json();
// Vérifier credentials (en prod : DB + bcrypt)
if (email !== "demo@exemple.sn" || password !== "demo") {
return c.json({ error: "Invalid credentials" }, 401);
}
const token = await sign(
{ sub: email, exp: Math.floor(Date.now() / 1000) + 3600 },
JWT_SECRET,
);
return c.json({ token });
});
// Middleware JWT pour les routes protégées
app.use("/api/private/*", jwt({ secret: JWT_SECRET }));
app.get("/api/private/me", (c) => {
const payload = c.get("jwtPayload");
return c.json({ user: payload });
});
Le middleware jwt() de Hono extrait le token du header Authorization: Bearer ..., le vérifie, et expose le payload via c.get("jwtPayload"). Si le token manque ou est invalide, 401 automatique.
Étape 5 — Connecter une base de données
L’in-memory store sert pour le prototype. En prod, utilisez PostgreSQL avec Drizzle ORM. Voir notre tutoriel Bun + Drizzle. Architecture typique :
// src/db/schema.ts (Drizzle)
import { pgTable, serial, varchar, integer } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
name: varchar("name", { length: 100 }).notNull(),
email: varchar("email", { length: 255 }).notNull().unique(),
age: integer("age").notNull(),
});
// src/db/client.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client);
Et dans la route :
import { db } from "../db/client";
import { users } from "../db/schema";
import { eq } from "drizzle-orm";
usersRoute.get("/:id", zValidator("param", userIdParam), async (c) => {
const { id } = c.req.valid("param");
const result = await db.select().from(users).where(eq(users.id, id));
if (!result.length) return c.json({ error: "Not found" }, 404);
return c.json(result[0]);
});
Étape 6 — Tests
// src/routes/users.test.ts
import { describe, it, expect } from "bun:test";
import app from "../index";
describe("Users API", () => {
it("GET /api/users returns array", async () => {
const res = await app.request("/api/users");
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body.data)).toBe(true);
});
it("POST /api/users validates input", async () => {
const res = await app.request("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "X" }), // age et email manquants
});
expect(res.status).toBe(400);
});
});
Lancez avec bun test. Pas de mock HTTP server à créer : Hono a une méthode app.request() qui simule des requêtes directement contre l’app, sans démarrer de serveur.
Étape 7 — Client RPC type-safe
L’une des killer features de Hono : générer un client TypeScript depuis vos routes, sans codegen :
// src/index.ts (côté server)
const route = app
.get("/api/users", (c) => c.json({ data: userStore }))
.post("/api/users", zValidator("json", createUserSchema), (c) => {
const body = c.req.valid("json");
return c.json({ id: 1, ...body }, 201);
});
export type AppType = typeof route;
// src/client.ts (côté client / front)
import { hc } from "hono/client";
import type { AppType } from "./index";
const client = hc<AppType>("https://api.exemple.sn");
const res = await client.api.users.$post({
json: { name: "X", email: "x@y.sn", age: 30 },
});
const user = await res.json();
// user est typé automatiquement
Plus de désynchro entre back et front. Si vous changez le schéma Zod côté serveur, le front compile en erreur jusqu’à ce que vous le mettiez à jour.
Étape 8 — Déployer
Sur Coolify avec Nixpacks : poussez sur Git, Coolify détecte Bun, lance bun install && bun run start. Sur un VPS nu : voir notre tutoriel systemd / PM2. Sur Cloudflare Workers : changez l’export pour export default app et déployez via Wrangler — ça marche sans modification supplémentaire.
Adaptation Afrique de l’Ouest
Pour une PME ouest-africaine qui construit une API métier (gestion de stock, facturation Wave/OM, application interne), Bun + Hono permet de tenir 5 000-10 000 utilisateurs concurrents sur un VPS Hetzner CX23 (4 € par mois). En face, un Express classique demanderait un VPS double pour le même service. L’économie annuelle est de 50-100 € par projet.
Erreurs fréquentes
| Erreur | Cause | Solution |
|---|---|---|
| CORS bloqué côté frontend | Origin manquante dans le middleware cors | Ajouter le domaine front dans origin: [] |
| JWT 401 systématique | JWT_SECRET différent entre login et middleware | Centraliser la lecture de la variable |
| Validation Zod ne coerce pas les types | Param URL toujours string | Utiliser .transform(Number) ou .coerce.number() |
| RPC client n’a pas les types | Routes pas chaînées | Chaîner .get().post() sur la même variable |
| app.request() retourne 404 en test | Préfixe de path manquant | Inclure le préfixe complet dans request() |
Sur un angle proche
- guide pratique Bun en production 2026
- Bun + Drizzle ORM avec PostgreSQL
- Déployer Bun avec systemd ou PM2
- Bun vs Node.js benchmarks 2026
- Documentation Hono : hono.dev/docs
- Documentation Zod : zod.dev
Déploiement et observabilité en production
Le runtime Bun a beaucoup mûri depuis sa version stable 1.0 de septembre 2023. Au moment d’écrire ces lignes, il est utilisé en production par plusieurs équipes pour des charges modérées (jusqu’à quelques milliers de requêtes par seconde sur un seul nœud) et son intégration avec Hono donne une stack particulièrement efficiente sur les VPS modestes très répandus en Afrique de l’Ouest. Pour passer du tutoriel au déploiement réel, trois sujets méritent une attention rigoureuse : le packaging, le monitoring et la gestion des erreurs.
Packaging et conteneurisation
Bun fournit une image Docker officielle oven/bun basée sur Debian slim, qui pèse environ 90 mégaoctets compressée — soit trois à quatre fois moins qu’une image Node.js équivalente. Pour réduire encore, on peut utiliser oven/bun:alpine qui descend à 50 mégaoctets. Le multi-stage build reste pertinent : on installe les dépendances dans une première étape avec le cache du registry npm, on copie ensuite uniquement le binaire et les modules de production dans une image finale minimale. La commande bun build --target=bun --minify produit un fichier JavaScript unique qui peut être embarqué dans une image distroless si l’on veut un attaque-surface minimale.
Observabilité : logs, métriques et traces
Hono fournit un middleware logger() qui imprime sur stdout au format texte par défaut. En production, il faut passer au format JSON structuré pour que Loki, Datadog ou un simple journalctl --output=json puisse parser et requêter. Le middleware accepte une fonction de sérialisation personnalisée. Côté métriques, le pattern le plus simple consiste à exposer un endpoint /metrics au format Prometheus à l’aide du package prom-client qui fonctionne sans modification sur Bun. Pour le tracing distribué, OpenTelemetry n’a pas encore d’auto-instrumentation officielle pour Bun à la date de cet article, mais l’instrumentation manuelle via le SDK @opentelemetry/api fonctionne correctement : on crée un span par requête entrante dans un middleware Hono et on l’enrichit avec les attributs HTTP standards.
Gestion des erreurs et résilience
Hono propose app.onError() pour centraliser le traitement des exceptions et app.notFound() pour le 404. En production, ne jamais renvoyer la stack trace au client : la masquer derrière un message générique et logger le détail côté serveur avec un identifiant de corrélation que l’on retourne dans le corps de la réponse. Cet identifiant permet au support de retrouver la trace exacte sans exposer la structure interne. Pour les appels externes (base de données, APIs tierces), envelopper systématiquement avec un timeout via AbortSignal.timeout(5000) et un retry exponentiel avec jitter — sans cela, une lenteur côté fournisseur fait écrouler tout le service par épuisement du pool de connexions.
Contexte ouest-africain et coût total
Sur un VPS Scaleway Stardust à 4 euros par mois (1 vCPU, 1 Go de RAM, situé à Paris avec une latence d’environ 90 millisecondes vers Dakar), une API Bun + Hono peut servir entre 800 et 1500 requêtes par seconde sur des endpoints CRUD simples avec une base de données PostgreSQL locale. Pour comparaison, la même application en Node.js + Express plafonne autour de 400 à 600 requêtes par seconde sur le même VPS — Bun reste deux à trois fois plus efficient sur des charges I/O. Pour un service B2B qui sert quelques centaines d’entreprises sénégalaises ou ivoiriennes avec un trafic concentré sur les heures ouvrées, cette efficience permet de tenir un an entier sur un VPS à moins de 50 euros, paiement Wave ou Orange Money accepté chez la plupart des hébergeurs européens via les nouvelles cartes Visa virtuelles.