← Back to DevBytes

Migrating from Express to Hono: Step-by-Step Guide

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:

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:

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

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles