Skip to content

Fix: Hono Not Working — Route Not Matching, Middleware Skipped, or RPC Client Type Mismatch

FixDevs ·

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 Found

Or 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 inference

Or 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 pathapp.use('/api/*', fn) matches /api/anything but not /api itself (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 the Hono instance 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 through c.env when 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_SECRET

Fix 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 runtimesc.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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles