What Is Hono?
Hono is a lightweight, ultrafast web framework for the edge, designed to run on Cloudflare Workers, Deno, Bun, Node.js, and other JavaScript runtimes. Its name comes from the Japanese word for "flame" — fitting for a framework that burns through requests at incredible speed. Hono draws inspiration from Express in its routing and middleware patterns, making it feel familiar to Express developers while offering modern features like first-class async support, built-in TypeScript integration, and a tiny footprint (around 14KB).
Express has been the dominant Node.js web framework for over a decade. It's battle-tested, well-documented, and powers millions of applications. However, Express was designed in the pre-async/await era and carries legacy patterns that can feel cumbersome today. Hono reimagines the Express-like developer experience for the modern web, with native support for Web Standards APIs like Request and Response, streaming, and edge deployment targets.
Why Migrate From Express to Hono?
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Performance and Bundle Size
Hono processes requests significantly faster than Express — benchmarks show 3–5x improvements in requests per second. Its tiny bundle size makes it ideal for serverless and edge functions where cold starts matter. Express, with its dependency tree, can weigh hundreds of kilobytes or more after bundling.
Web Standards Alignment
Hono is built on the standard Request/Response API rather than Node.js-specific req/res objects. This means your handlers work identically across Cloudflare Workers, Deno, Bun, and Node.js with zero platform-specific code. Express relies on Node.js HTTP objects that don't exist in other runtimes.
Async-First Design
Every Hono handler is async by default. You never need to call next() manually in async middleware — just await the next handler. This eliminates the common Express pitfall where developers forget to call next() or accidentally call it after sending a response.
TypeScript Excellence
Hono is written in TypeScript and provides exceptional type inference. Route parameters, request context, and middleware chains are fully typed without extra generics. Express requires additional packages like @types/express and often needs manual type annotations.
Edge-Ready
Hono runs natively on Cloudflare Workers, Vercel Edge Functions, AWS Lambda, and other edge platforms. Express requires Node.js and cannot run at the edge without polyfills or adapter layers that add complexity and reduce performance.
Step-by-Step Migration Guide
1. Installing Hono and Setting Up the Project
First, install Hono in your project. If you're starting fresh within an existing Express codebase, you can install it alongside Express during the transition period:
npm install hono
Create a new entry file for Hono — let's call it hono-server.ts (or .js if not using TypeScript). The basic Hono application structure mirrors Express:
// Express version (app.js)
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.json({ message: 'Hello from Express' });
});
app.listen(3000, () => {
console.log('Express server on port 3000');
});
// ----------------------------------------------------
// Hono version (hono-server.ts)
import { Hono } from 'hono';
import { serve } from '@hono/node-server'; // for Node.js
const app = new Hono();
app.get('/', (c) => {
return c.json({ message: 'Hello from Hono' });
});
serve(app, { port: 3000 });
console.log('Hono server on port 3000');
Key differences to notice immediately:
- Handlers receive a Context object called
cinstead ofreqandres. - You return the response value instead of calling
res.json()orres.send(). - The
@hono/node-serveradapter bridges Hono into Node.js's HTTP server — you'll use different adapters for different platforms.
2. Converting Routes and HTTP Methods
Express uses app.get(), app.post(), app.put(), app.delete(), and app.use() for middleware. Hono mirrors these exact method names, making route conversion mechanical:
// Express routes
app.get('/api/users', (req, res) => {
const { page } = req.query;
res.json({ users: [], page });
});
app.get('/api/users/:id', (req, res) => {
const userId = req.params.id;
res.json({ id: userId, name: 'John' });
});
app.post('/api/users', (req, res) => {
const { name, email } = req.body;
res.status(201).json({ name, email });
});
// ----------------------------------------------------
// Hono routes
app.get('/api/users', (c) => {
const page = c.req.query('page');
return c.json({ users: [], page });
});
app.get('/api/users/:id', (c) => {
const userId = c.req.param('id');
return c.json({ id: userId, name: 'John' });
});
app.post('/api/users', async (c) => {
const body = await c.req.json();
const { name, email } = body;
return c.json({ name, email }, 201);
});
Important changes:
req.querybecomesc.req.query('key')— a function call returning a single value, not an object.req.params.idbecomesc.req.param('id').req.bodyrequiresawait c.req.json()(or.text(),.formData()) because body parsing is async in Web Standards.- Status codes are passed as the second argument to
c.json(),c.text(),c.html(), etc.
3. Converting Query String Access
If your Express code uses req.query as a full object, you can get the same behavior in Hono:
// Express: req.query gives an object
app.get('/search', (req, res) => {
const { q, limit, offset } = req.query;
// q, limit, offset are all strings
});
// Hono: c.req.query() with no argument returns all query params
app.get('/search', (c) => {
const queries = c.req.query();
const { q, limit, offset } = queries;
// same behavior
});
// Hono also supports multiple values for a single key
app.get('/items', (c) => {
const categories = c.req.queries('category'); // returns string[]
});
4. Migrating Middleware
This is where the migration becomes more nuanced. Express middleware uses the signature (req, res, next). Hono middleware uses (c, next) and returns a response or awaits next():
// Express middleware — logging example
const expressLogger = (req, res, next) => {
console.log(`${req.method} ${req.url}`);
next();
};
app.use(expressLogger);
// Hono middleware — same logging
const honoLogger = async (c, next) => {
console.log(`${c.req.method} ${c.req.url}`);
await next();
};
app.use(honoLogger);
For middleware that needs to modify the request context (like attaching user data), Hono uses c.set() and c.get() instead of mutating req:
// Express: attach user to request object
const authMiddleware = (req, res, next) => {
const token = req.headers.authorization;
req.user = decodeToken(token); // mutating req
next();
};
// Hono: use context storage
const authMiddleware = async (c, next) => {
const token = c.req.header('Authorization');
c.set('user', decodeToken(token));
await next();
};
// Access in downstream handler
app.get('/profile', (c) => {
const user = c.get('user');
return c.json({ user });
});
Express middleware that terminates the request (like rate limiters) maps naturally:
// Express — rate limiter that blocks
const rateLimiter = (req, res, next) => {
if (isOverLimit(req.ip)) {
return res.status(429).json({ error: 'Too many requests' });
}
next();
};
// Hono — return directly without calling next()
const rateLimiter = async (c, next) => {
const ip = c.req.header('CF-Connecting-IP') || 'unknown';
if (isOverLimit(ip)) {
return c.json({ error: 'Too many requests' }, 429);
}
await next();
};
5. Error Handling Conversion
Express error handling middleware uses a special 4-parameter signature (err, req, res, next). Hono provides a cleaner app.onError hook:
// Express error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal Server Error' });
});
// Hono error handler
app.onError((err, c) => {
console.error(err.stack);
return c.json({ error: 'Internal Server Error' }, 500);
});
For 404 handling:
// Express — typically the last route defined
app.use((req, res) => {
res.status(404).json({ error: 'Not Found' });
});
// Hono — use notFound hook
app.notFound((c) => {
return c.json({ error: 'Not Found', path: c.req.url }, 404);
});
6. Converting Built-in Middleware (Body Parsing, CORS, Static Files)
Express bundles body parsers via express.json() and express.urlencoded(). Hono requires explicit body parsing or built-in middleware imports:
// Express — built-in body parsers
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Hono — body parsing is async per-request, no global middleware needed
// Just call await c.req.json() or await c.req.parseBody() in handlers
// For convenience, you can use Hono's body limit middleware:
import { bodyLimit } from 'hono/body-limit';
app.use('/upload', bodyLimit({ maxSize: 10 * 1024 * 1024 })); // 10MB
For CORS, Express typically uses the cors npm package. Hono has built-in CORS middleware:
// Express with cors package
const cors = require('cors');
app.use(cors({ origin: 'https://example.com' }));
// Hono built-in CORS
import { cors } from 'hono/cors';
app.use('*', cors({
origin: 'https://example.com',
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization'],
}));
For serving static files in Node.js, Express uses express.static(). Hono offers serveStatic:
// Express
app.use(express.static('public'));
// Hono (Node.js only — different adapters for edge platforms)
import { serveStatic } from '@hono/node-server/serve-static';
app.use('/static/*', serveStatic({ root: './public' }));
7. Route Grouping and Nested Routers
Express uses express.Router() for modular route groups. Hono uses the same pattern with new Hono() instances mounted as sub-apps:
// Express — router grouping
const userRouter = express.Router();
userRouter.get('/profile', (req, res) => { /* ... */ });
userRouter.put('/settings', (req, res) => { /* ... */ });
app.use('/api/users', userRouter);
// Hono — sub-application mounting
const userRouter = new Hono();
userRouter.get('/profile', (c) => { return c.json({}); });
userRouter.put('/settings', async (c) => {
const body = await c.req.json();
return c.json({ updated: true });
});
app.route('/api/users', userRouter);
Hono also supports a fluent .route() chaining pattern for organizing related handlers:
const api = new Hono();
api.get('/health', (c) => c.json({ status: 'ok' }));
api.get('/version', (c) => c.json({ version: '2.0.0' }));
app.route('/api', api);
8. Migrating Template Rendering
If your Express app uses view engines like EJS, Pug, or Handlebars, Hono provides c.html() for returning HTML strings and supports template rendering via middleware:
// Express with EJS
app.set('view engine', 'ejs');
app.get('/welcome', (req, res) => {
res.render('welcome', { name: req.query.name });
});
// Hono with html helper — manual template approach
import { html } from 'hono/html';
app.get('/welcome', (c) => {
const name = c.req.query('name');
return c.html(html`<h1>Welcome, ${name}!</h1>`);
});
// For full template engines, use the jsx renderer or a template middleware
// Hono's JSX support is built-in and powerful:
app.get('/welcome', (c) => {
const name = c.req.query('name');
return c.html(<h1>Welcome, {name}!</h1>); // JSX works natively
});
9. Handling File Uploads
Express typically uses multer for multipart file uploads. Hono handles multipart data via c.req.parseBody() and the built-in multipart helper:
// Express with multer
const multer = require('multer');
const upload = multer({ dest: 'uploads/' });
app.post('/upload', upload.single('file'), (req, res) => {
res.json({ filename: req.file.originalname });
});
// Hono — built-in multipart handling
import { stream } from 'hono/streaming';
app.post('/upload', async (c) => {
const body = await c.req.parseBody();
const file = body['file'];
// file is a File object from Web Standards
res.json({ filename: file.name, size: file.size });
});
10. Environment Variables and Configuration
Express apps commonly use process.env or dotenv. Hono works the same way in Node.js but also supports platform-specific bindings (like Cloudflare Workers KV, secrets, etc.) through its Context:
// Express
app.get('/config', (req, res) => {
res.json({ env: process.env.NODE_ENV });
});
// Hono — process.env still works in Node.js
app.get('/config', (c) => {
return c.json({ env: process.env.NODE_ENV });
});
// Hono also supports typed environment bindings
type Bindings = {
DATABASE_URL: string;
API_KEY: string;
};
const app = new Hono<{ Bindings: Bindings }>();
app.get('/db-info', (c) => {
const dbUrl = c.env.DATABASE_URL; // fully typed
return c.json({ connected: !!dbUrl });
});
Complete Migration Example: A REST API
Let's walk through a complete Express-to-Hono migration for a typical REST API with authentication, CRUD routes, and error handling:
// ============================================
// EXPRESS VERSION (original)
// ============================================
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors());
app.use(express.json());
// Auth middleware
const auth = (req, res, next) => {
const token = req.headers.authorization;
if (!token) return res.status(401).json({ error: 'Unauthorized' });
req.userId = verifyToken(token);
next();
};
// Routes
app.get('/api/todos', auth, async (req, res) => {
try {
const todos = await db.todos.findMany({ where: { userId: req.userId } });
res.json({ todos });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/todos', auth, async (req, res) => {
try {
const { title } = req.body;
const todo = await db.todos.create({ data: { title, userId: req.userId } });
res.status(201).json({ todo });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.use((err, req, res, next) => {
res.status(500).json({ error: 'Server error' });
});
app.listen(3000);
// ============================================
// HONO VERSION (migrated)
// ============================================
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { serve } from '@hono/node-server';
type Bindings = {
DB_URL: string;
};
const app = new Hono<{ Bindings: Bindings }>();
// Global middleware
app.use('*', cors());
// Auth middleware
const auth = async (c, next) => {
const token = c.req.header('Authorization');
if (!token) return c.json({ error: 'Unauthorized' }, 401);
const userId = verifyToken(token);
c.set('userId', userId);
await next();
};
// GET /api/todos
app.get('/api/todos', auth, async (c) => {
try {
const userId = c.get('userId');
const todos = await db.todos.findMany({ where: { userId } });
return c.json({ todos });
} catch (err) {
return c.json({ error: err.message }, 500);
}
});
// POST /api/todos
app.post('/api/todos', auth, async (c) => {
try {
const body = await c.req.json();
const { title } = body;
const userId = c.get('userId');
const todo = await db.todos.create({ data: { title, userId } });
return c.json({ todo }, 201);
} catch (err) {
return c.json({ error: err.message }, 500);
}
});
// Error handling
app.onError((err, c) => {
return c.json({ error: 'Server error' }, 500);
});
// Start
serve(app, { port: 3000 });
Best Practices for a Smooth Migration
Migrate Incrementally
Don't attempt a full rewrite in one go. You can run Hono alongside Express on different ports or mount an Express app inside Hono using Node.js adapters. Start with a single route group, verify it works, then expand:
// Running both frameworks simultaneously
serve(honoApp, { port: 3000 }); // Hono on 3000
// Express still running on 3001 temporarily
Use TypeScript From Day One
Hono's type system catches migration errors at compile time. The compiler will flag mismatched return types, missing await calls on body parsing, and incorrect context access patterns — saving hours of debugging.
Create Shared Middleware Adapters
If you have complex Express middleware you can't immediately rewrite, wrap it in a Hono-compatible adapter:
import { createMiddleware } from 'hono/factory';
const adaptExpressMiddleware = (expressMw) => {
return createMiddleware(async (c, next) => {
// Wrap Node.js req/res to mimic Express
const req = c.req.raw;
// Call the legacy middleware with a Promise wrapper
await new Promise((resolve, reject) => {
const res = { /* mock res object */ };
expressMw(req, res, (err) => {
if (err) reject(err);
else resolve();
});
});
await next();
});
};
Test Thoroughly at Each Step
Write integration tests that work against both implementations. Since Hono handlers return Response objects, you can test them without spinning up a server:
// Testing a Hono handler directly
import { test, expect } from 'vitest';
test('GET /api/todos returns 200', async () => {
const res = await app.request('/api/todos', {
headers: { Authorization: 'Bearer valid-token' }
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toHaveProperty('todos');
});
Leverage Hono's Built-in Features
Don't just mechanically translate Express patterns. Take advantage of Hono-specific capabilities like c.redirect(), c.notFound(), streaming responses, and the built-in validator:
// Hono's built-in validation with zod
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
const todoSchema = z.object({
title: z.string().min(1),
completed: z.boolean().optional(),
});
app.post('/api/todos', auth, zValidator('json', todoSchema), async (c) => {
const body = c.req.valid('json'); // already validated and typed
const todo = await db.todos.create({ data: body });
return c.json({ todo }, 201);
});
Watch Out for These Common Pitfalls
- Forgetting to await body parsing:
c.req.json()returns a Promise. Always useawait. - Not returning the response: Hono handlers must return a Response. If you call
c.json()withoutreturn, the handler returns void and the request hangs. - Mixing
req/respatterns: Don't try to usereq.paramsorres.send()— those don't exist on the Context object. - Using Express-specific packages directly: Packages like
express-async-errorsor middleware that mutatesreqwon't work without adaptation.
Conclusion
Migrating from Express to Hono is a strategic upgrade that brings your application into the modern web standards era while preserving the familiar routing and middleware patterns you already know. The conversion process is largely mechanical — renaming req/res to c, adding return statements, and awaiting body parsing — but the benefits compound quickly: faster response times, smaller deployment packages, native TypeScript safety, and the ability to deploy anywhere from Node.js servers to the edge. By following the incremental migration strategy outlined here, converting middleware patterns carefully, and embracing Hono's built-in features like validators and streaming, you can modernize your stack with minimal disruption and maximum payoff. The flame burns brighter on the other side.