Fix: Hono Not Working — Route Not Matching, Middleware Skipped, or RPC Client Type Mismatch
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix Hono framework issues — routing order, middleware chaining, Hono RPC type inference, Cloudflare Workers bindings, validator integration, and runtime compatibility.
The Problem
A route returns 404 despite being defined:
const app = new Hono();
app.get('/api/users/:id', (c) => {
return c.json({ id: c.req.param('id') });
});
// GET /api/users/123 → 404 Not FoundOr middleware isn’t running for some routes:
app.use('/api/*', authMiddleware);
app.get('/api/users', (c) => c.json([])); // authMiddleware runs
app.get('/api/users/:id', (c) => c.json({})); // authMiddleware doesn't run?Or the Hono RPC client loses TypeScript types:
// server
const route = app.get('/users', (c) => c.json([{ id: 1, name: 'Alice' }]));
export type AppType = typeof route;
// client
const client = hc<AppType>('http://localhost:3000');
const res = await client.users.$get();
const data = await res.json();
// data is 'unknown' — no type inferenceOr Cloudflare Workers bindings (KV, D1, R2) are undefined:
app.get('/data', (c) => {
const kv = c.env.MY_KV; // undefined at runtime
return c.json({ data: await kv.get('key') });
});Why This Happens
Hono has specific patterns that differ from Express, and the differences are easy to miss because the API surface looks almost identical. The first instinct when a route 404s is to check the route definition for a typo, but Hono routes that “look right” can still fail because of middleware ordering, mounting paths, runtime adapter mismatch, or context binding loss across async boundaries.
The deepest source of Hono bugs in production is the runtime layer. Hono runs on Cloudflare Workers, Node.js, Bun, Deno, Vercel Edge, AWS Lambda, and more. Each runtime has subtly different compatibility expectations for Request, Response, streams, and the c.env shape. The Cloudflare Workers compat date in wrangler.toml decides whether Response.json(), crypto.subtle, and several fetch features are available — bump the date and unrelated routes can break. Set it too old and modern Hono middleware silently fails.
The other recurring source is context binding loss. c is bound to the current request, and passing a method reference (app.get('/x', handler.handle)) instead of an arrow function unbinds it. The error doesn’t surface as “context lost” — it surfaces as c.env is undefined or c.req.param is not a function, which sends you on a wild goose chase through the bindings config.
- Routing order matters, but Hono is not Express — Hono matches routes in definition order, but unlike Express, it doesn’t stop at the first match by default in all cases. Middleware paths use glob patterns that must match exactly.
app.use()middleware paths must match the request path —app.use('/api/*', fn)matches/api/anythingbut not/apiitself (without trailing path). The*in Hono requires at least one character after the slash.- Hono RPC requires exporting the chained route, not the app — the RPC type inference works from the return value of chained
.get(),.post()etc. calls, not from theHonoinstance itself. - Cloudflare Workers bindings come from the second argument — in a Cloudflare Worker handler, bindings are in
env, passed as the second argument. Hono exposes them throughc.envwhen using the correct types.
Diagnostic Timeline
A typical Hono debugging session for a “route not working” report unfolds like this:
Minute 0 — first guess: check the routes. You look at app.get('/api/users', ...), confirm the path is correct, hit the endpoint, and still get a 404. You add console.log inside the handler — it never fires. The instinct is to suspect routing order, so you move the route to the top of the file. Still nothing.
Minute 4 — check middleware order. The route is registered, but a middleware earlier in the chain is returning a response without calling await next(). Add a log right before every next() call. Common cause: an auth middleware that returns c.json({ error: 'Unauthorized' }, 401) without an early return for public paths, so even authenticated requests bounce. Another common cause is app.use('*', logger()) placed after the route definitions — middleware mounted after a route won’t apply to it.
Minute 9 — Cloudflare Workers compat mode mismatch. If the route works in wrangler dev but fails in production, check wrangler.toml for compatibility_date. Hono uses Response.json() and other modern Web APIs that require a recent compat date. Set it to a date within the last six months. Also confirm compatibility_flags = ["nodejs_compat"] if any of your middleware imports a Node built-in (hono/cookie uses crypto directly, which works without the flag, but some validator packages bundle Node polyfills).
Minute 14 — context binding loss across async boundaries. Inside a handler that awaits a long-running operation, c.env returns undefined or c.req.param('id') throws “is not a function.” The cause is that c was destructured and a method called from outside its closure — Hono’s context is a class instance, not a plain object. Never write const { env } = c; await someAsync(); env.MY_KV.get(...) if someAsync spawns a new microtask that survives the request. Read the binding once, hold the value, never the binding accessor.
Minute 20 — RPC client losing types. Routes work at runtime but client.users.$get() returns unknown. The cause is almost always that the type export is typeof app from a file that already split routes across multiple files. The chained .get().post() builder must be evaluated in the file you export the type from. If you call app.get(...) and discard the return value, the RPC types end at the last chained call. Re-chain or use .route('/users', usersApp) and export the type of the parent app.
Fix 1: Route Definition and Ordering
import { Hono } from 'hono';
const app = new Hono();
// Routes are matched in definition order
// More specific routes must come BEFORE general ones
// WRONG — general route catches everything
app.get('/api/:resource', (c) => c.json({ type: 'generic' }));
app.get('/api/users', (c) => c.json({ users: [] })); // Never reached!
// CORRECT — specific routes first
app.get('/api/users', (c) => c.json({ users: [] })); // Specific
app.get('/api/users/:id', (c) => c.json({ id: c.req.param('id') }));
app.get('/api/:resource', (c) => c.json({ type: 'generic' })); // Catch-all last
// Route parameters
app.get('/users/:id', (c) => {
const id = c.req.param('id');
return c.json({ id });
});
// Multiple params
app.get('/orgs/:orgId/repos/:repoId', (c) => {
const { orgId, repoId } = c.req.param(); // Get all params at once
return c.json({ orgId, repoId });
});
// Optional params with regex
app.get('/users/:id{[0-9]+}', (c) => { // Only numeric IDs
return c.json({ id: Number(c.req.param('id')) });
});
// Wildcard routes
app.get('/files/*', (c) => {
const path = c.req.param('*'); // Everything after /files/
return c.text(`File: ${path}`);
});Method chaining for cleaner routes:
// Group related routes
const users = new Hono()
.get('/', (c) => c.json({ users: [] }))
.post('/', async (c) => {
const body = await c.req.json();
return c.json(body, 201);
})
.get('/:id', (c) => c.json({ id: c.req.param('id') }))
.put('/:id', async (c) => {
const body = await c.req.json();
return c.json(body);
})
.delete('/:id', (c) => c.json({ deleted: c.req.param('id') }));
const app = new Hono()
.route('/users', users)
.route('/posts', posts);
export default app;Fix 2: Middleware Setup and Chaining
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { bearerAuth } from 'hono/bearer-auth';
const app = new Hono();
// Global middleware — applies to all routes
app.use('*', logger());
app.use('*', cors({
origin: ['https://app.example.com', 'http://localhost:3000'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization'],
}));
// Path-specific middleware — note: '/api/*' matches /api/x but NOT /api exactly
app.use('/api/*', bearerAuth({ token: 'secret' }));
// Fix for matching /api AND /api/*:
app.use('/api', authMiddleware);
app.use('/api/*', authMiddleware);
// Custom middleware
const authMiddleware = async (c: Context, next: Next) => {
const token = c.req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
return c.json({ error: 'Unauthorized' }, 401);
}
try {
const user = await verifyToken(token);
c.set('user', user); // Pass data to route handlers
await next();
} catch {
return c.json({ error: 'Invalid token' }, 401);
}
};
// Access middleware-set data in route
app.get('/api/profile', authMiddleware, (c) => {
const user = c.get('user'); // Data set by middleware
return c.json(user);
});
// Inline middleware for specific routes
app.get('/admin/*',
async (c, next) => {
const user = c.get('user');
if (user?.role !== 'admin') return c.json({ error: 'Forbidden' }, 403);
await next();
},
(c) => c.json({ admin: true })
);Fix 3: Hono RPC — Type-Safe Client
For end-to-end type safety, chain routes from a single app and export the type correctly:
// server.ts — CORRECT way to export RPC types
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const app = new Hono()
.get('/users', (c) => {
return c.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
] as const);
})
.post('/users',
zValidator('json', z.object({
name: z.string().min(1),
email: z.string().email(),
})),
async (c) => {
const { name, email } = c.req.valid('json');
const user = await createUser({ name, email });
return c.json(user, 201);
}
)
.get('/users/:id', (c) => {
const id = Number(c.req.param('id'));
return c.json({ id, name: 'Alice' });
});
// Export the app TYPE — not just typeof app, but the chained type
export type AppType = typeof app;
export default app;
// client.ts
import { hc } from 'hono/client';
import type { AppType } from './server';
const client = hc<AppType>('http://localhost:3000');
// Fully typed!
const res = await client.users.$get();
const users = await res.json();
// users: { id: number; name: string }[]
const createRes = await client.users.$post({
json: { name: 'Charlie', email: '[email protected]' },
});
const newUser = await createRes.json();
// newUser is typed
// Route with params
const userRes = await client.users[':id'].$get({
param: { id: '1' },
});
const user = await userRes.json();RPC in a monorepo — share types via a package:
// packages/api/src/router.ts
export const app = new Hono()
.get('/health', (c) => c.json({ ok: true }))
// ... more routes
export type ApiType = typeof app;
// apps/web/src/lib/api.ts
import { hc } from 'hono/client';
import type { ApiType } from '@my-monorepo/api';
export const api = hc<ApiType>(import.meta.env.VITE_API_URL);Fix 4: Cloudflare Workers and Bindings
// worker.ts — typed bindings via generics
type Bindings = {
MY_KV: KVNamespace;
MY_DB: D1Database;
MY_BUCKET: R2Bucket;
MY_SECRET: string; // Secret from wrangler.toml
ENVIRONMENT: string; // Plain env var
};
const app = new Hono<{ Bindings: Bindings }>();
app.get('/kv/:key', async (c) => {
// c.env is fully typed
const value = await c.env.MY_KV.get(c.req.param('key'));
if (!value) return c.json({ error: 'Key not found' }, 404);
return c.json({ value });
});
app.post('/kv/:key', async (c) => {
const body = await c.req.text();
await c.env.MY_KV.put(c.req.param('key'), body, {
expirationTtl: 3600, // Expire after 1 hour
});
return c.json({ ok: true });
});
app.get('/db/users', async (c) => {
const result = await c.env.MY_DB
.prepare('SELECT * FROM users LIMIT 10')
.all();
return c.json(result.results);
});
// Share Variables across middleware and handlers
type Variables = {
userId: string;
requestId: string;
};
const app = new Hono<{ Bindings: Bindings; Variables: Variables }>();
app.use('*', async (c, next) => {
c.set('requestId', crypto.randomUUID());
await next();
});
app.use('/api/*', async (c, next) => {
const token = c.req.header('Authorization')?.replace('Bearer ', '');
const userId = await verifyToken(token, c.env.MY_SECRET);
c.set('userId', userId);
await next();
});
app.get('/api/me', (c) => {
return c.json({ userId: c.get('userId') }); // Typed from Variables
});wrangler.toml — declare bindings:
name = "my-worker"
main = "src/worker.ts"
compatibility_date = "2024-01-01"
[[kv_namespaces]]
binding = "MY_KV"
id = "your-kv-namespace-id"
[[d1_databases]]
binding = "MY_DB"
database_name = "my-database"
database_id = "your-database-id"
[vars]
ENVIRONMENT = "production"
[secrets]
MY_SECRET = "..." # Set via: wrangler secret put MY_SECRETFix 5: Request Validation
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const app = new Hono();
// JSON body validation
app.post('/users',
zValidator('json', z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
age: z.number().int().min(18),
})),
(c) => {
const { name, email, age } = c.req.valid('json'); // Typed!
return c.json({ name, email, age }, 201);
}
);
// Query params validation
app.get('/users',
zValidator('query', z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().optional(),
})),
(c) => {
const { page, limit, search } = c.req.valid('query');
return c.json({ page, limit, search });
}
);
// Path params validation
app.get('/users/:id',
zValidator('param', z.object({
id: z.coerce.number().int().positive(),
})),
(c) => {
const { id } = c.req.valid('param'); // id is number, not string
return c.json({ id });
}
);
// Custom error handling for validation
app.post('/data',
zValidator('json', schema, (result, c) => {
if (!result.success) {
return c.json({
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
}, 400);
}
}),
(c) => c.json({ ok: true })
);Fix 6: Error Handling and Responses
import { Hono, HTTPException } from 'hono';
import { HTTPException } from 'hono/http-exception';
const app = new Hono();
// Throw HTTPException in handlers
app.get('/users/:id', async (c) => {
const user = await db.findUser(c.req.param('id'));
if (!user) {
throw new HTTPException(404, { message: 'User not found' });
}
return c.json(user);
});
// Global error handler
app.onError((err, c) => {
if (err instanceof HTTPException) {
return err.getResponse(); // Returns the pre-built Response
}
console.error('Unexpected error:', err);
return c.json({ error: 'Internal server error' }, 500);
});
// 404 handler
app.notFound((c) => {
return c.json({ error: `Route ${c.req.method} ${c.req.path} not found` }, 404);
});
// Response helpers
app.get('/redirect', (c) => c.redirect('/new-path', 301));
app.get('/text', (c) => c.text('Hello World'));
app.get('/html', (c) => c.html('<h1>Hello</h1>'));
app.get('/stream', (c) => {
return c.streamText(async (stream) => {
for (const chunk of ['Hello', ' ', 'World']) {
await stream.write(chunk);
await stream.sleep(100);
}
});
});Still Not Working?
Hono running in Node.js returns wrong status codes — ensure you’re using the Node.js adapter, not the default export which targets Cloudflare Workers:
// For Node.js
import { serve } from '@hono/node-server';
import app from './app';
serve({ fetch: app.fetch, port: 3000 });
// For Bun
export default { port: 3000, fetch: app.fetch };
// For Cloudflare Workers — just export default
export default app;Middleware not running for OPTIONS requests (CORS preflight) — CORS preflight sends OPTIONS before the actual request. If your route only handles GET/POST, OPTIONS falls through to a 404. Use app.use('*', cors()) before any route definitions to handle OPTIONS automatically. The cors() middleware from hono/cors handles preflight correctly.
c.req.json() throws in some runtimes — c.req.json() reads the request body as JSON. If the Content-Type header isn’t application/json, it may throw. Check the header: c.req.header('content-type'). Alternatively, use c.req.text() and parse manually with JSON.parse().
RPC client type is correct but JSON parse fails at runtime — Hono RPC types describe the handler return type, not the actual response body. If your handler returns c.json({ users }) but your error path returns c.text('boom', 500), the client expects JSON and crashes on the error response. Always normalize error responses to JSON in a global app.onError, and check res.ok on the client before calling res.json().
Subapp route() swallows trailing slash differences — app.route('/api/users', users) mounts the sub-app. Inside users, the path / matches /api/users and /api/users/ differently depending on whether the request URL has a trailing slash. The browser may send one form and fetch the other. Add an explicit redirect or normalize in middleware: if (c.req.path.endsWith('/') && c.req.path !== '/') return c.redirect(c.req.path.slice(0, -1), 308).
Cloudflare Workers env binding missing in wrangler dev but present in production — wrangler dev reads bindings from wrangler.toml only by default. If your secret was set via wrangler secret put (which writes to the deployed worker), wrangler dev won’t see it unless you also set it locally with wrangler secret put --local or in .dev.vars. Confirm with console.log(Object.keys(c.env)) at the top of a handler.
For related backend framework and runtime issues, see Fix: Express Middleware Not Working, Fix: FastAPI Background Tasks Not Working, Fix: Wrangler Not Working, and Fix: Cloudflare Workers AI Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Wrangler Not Working — Deploy Failing, Bindings Not Found, or D1 Queries Returning Empty
How to fix Wrangler and Cloudflare Workers issues — wrangler.toml configuration, KV and D1 bindings, R2 storage, environment variables, local dev with Miniflare, and deployment troubleshooting.
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: pdf-lib Not Working — PDF Not Generating, Fonts Not Embedding, or Pages Blank
How to fix pdf-lib issues — creating PDFs from scratch, modifying existing PDFs, embedding fonts and images, form filling, merging documents, and browser and Node.js usage.
Fix: Pusher Not Working — Events Not Received, Channel Auth Failing, or Connection Dropping
How to fix Pusher real-time issues — client and server setup, channel types, presence channels, authentication endpoints, event binding, connection management, and React integration.