Fix: Hono Not Working — Route Not Matching, Middleware Skipped, or RPC Client Type Mismatch
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:
- 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.
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().
For related backend framework issues, see Fix: Express Middleware Not Working and Fix: FastAPI Background Tasks 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.