Production Hono patterns — zValidator hooks, typed generics, error handling, middleware composition, testing, and multi-runtime deployment
87
80%
Does it follow best practices?
Impact
98%
2.57xAverage score across 5 eval scenarios
Passed
No known issues
Production-grade patterns that belong in every Hono application from day one. When building any Hono API -- whether a quick prototype or a production service -- always include these patterns without being asked.
Hono is a lightweight, multi-runtime framework. Without these patterns from the start:
zValidator defaults to returning 400 with a plain-text body. Clients expecting JSON get unparseable text. You must provide a hook to return structured JSON errors.c.set() / c.get() without declaring Variables in the generic means runtime crashes on typos and no IDE autocompletion.c.env.DATABASE without a Bindings type means c.env is any and typos in binding names fail silently at runtime..get() calls instead of method chaining on a single Hono instance prevents hc from inferring the full route type.export default app on Node.js (needs @hono/node-server) or Deno.serve(app.fetch) on Cloudflare causes silent failures.These are not edge cases. They are the first things that break in production.
Always declare your app's environment types upfront. Group routes with app.route():
import { Hono } from 'hono';
// Declare environment types for type-safe c.set()/c.get() and c.env
type AppEnv = {
Variables: {
requestId: string;
userId: string;
};
// For Cloudflare Workers, declare Bindings:
// Bindings: {
// DATABASE: D1Database;
// KV_STORE: KVNamespace;
// };
};
const app = new Hono<AppEnv>();
// Group related routes on separate Hono instances
const api = new Hono<AppEnv>();
api.get('/menu', async (c) => {
const items = await getMenuItems();
return c.json({ data: items });
});
api.post('/orders', async (c) => {
const body = await c.req.json();
const order = await createOrder(body);
return c.json({ data: order }, 201);
});
// Mount the group
app.route('/api', api);Key points:
Env generic to both parent and child Hono instances so c.set()/c.get() and c.env stay type-safe across route groups.app.route('/prefix', child) mounts all child routes under the prefix. The child's paths are relative.Bindings in the generic -- otherwise c.env is untyped and binding name typos fail silently at runtime.The zValidator middleware from @hono/zod-validator validates request data. Critical: without a hook callback, validation failures return 400 with a plain-text body, not JSON. Always provide a hook:
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const createOrderSchema = z.object({
customerName: z.string().min(1).max(100),
items: z.array(z.object({
menuItemId: z.number().int().positive(),
size: z.enum(['small', 'medium', 'large']),
quantity: z.number().int().min(1).max(20),
})).min(1),
});
// CORRECT: hook returns structured JSON on failure
api.post('/orders',
zValidator('json', createOrderSchema, (result, c) => {
if (!result.success) {
return c.json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request body',
details: result.error.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message,
})),
},
}, 400);
}
}),
async (c) => {
const body = c.req.valid('json'); // Fully typed from schema
const order = await createOrder(body);
return c.json({ data: order }, 201);
}
);Key points:
hook third argument receives { success, data?, error? } and the context c. Return a Response on failure; return nothing on success to proceed to the handler.c.req.valid('json') in the handler to get the validated, fully-typed data. Never use c.req.json() after zValidator -- it re-parses and loses type info.'json', 'query', 'param', 'header', 'cookie', and 'form' targets.import { cors } from 'hono/cors';
import { secureHeaders } from 'hono/secure-headers';
import { logger } from 'hono/logger';
import { timing } from 'hono/timing';
// Built-in middleware -- register before routes
app.use('*', logger());
app.use('*', secureHeaders());
app.use('*', timing());
app.use('/api/*', cors({
origin: ['https://example.com'],
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE'],
}));Custom middleware -- always await next():
import { createMiddleware } from 'hono/factory';
// CORRECT: use createMiddleware for type-safe custom middleware
const requestIdMiddleware = createMiddleware<AppEnv>(async (c, next) => {
const requestId = c.req.header('x-request-id') || crypto.randomUUID();
c.set('requestId', requestId); // Type-safe thanks to AppEnv generic
c.header('x-request-id', requestId);
await next(); // MUST await next() -- forgetting await breaks downstream middleware
});
app.use('/api/*', requestIdMiddleware);Key points:
await next() in middleware. Forgetting await causes downstream middleware and the response to execute out of order.createMiddleware<Env>() from hono/factory to get typed c.set()/c.get() in custom middleware. Plain async (c, next) => {} functions lose the generic.'*' for all routes, '/api/*' for a prefix.import { HTTPException } from 'hono/http-exception';
// Custom error classes extending HTTPException
class NotFoundError extends HTTPException {
constructor(resource: string, id: string) {
super(404, { message: `${resource} ${id} not found` });
}
}
class ConflictError extends HTTPException {
constructor(message: string) {
super(409, { message });
}
}
// Global error handler -- register on the app
app.onError((err, c) => {
if (err instanceof HTTPException) {
const status = err.status;
return c.json({
error: {
code: status === 404 ? 'NOT_FOUND' : status === 409 ? 'CONFLICT' : 'ERROR',
message: err.message,
},
}, status);
}
// Unknown error -- never leak internals
console.error(err);
return c.json({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
},
}, 500);
});
// 404 handler for unmatched routes
app.notFound((c) => {
return c.json({
error: {
code: 'NOT_FOUND',
message: 'Endpoint not found',
},
}, 404);
});Key points:
app.onError is Hono's global error handler. It catches both thrown errors and HTTPExceptions.app.notFound handles requests that match no route -- without it, Hono returns a plain-text 404.HTTPException for typed errors. Use super(status, { message }).{ error: { code, message } }.Hono has a built-in test helper -- do not use supertest:
import { describe, it, expect } from 'vitest';
describe('Orders API', () => {
it('creates an order', async () => {
const res = await app.request('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customerName: 'Alice',
items: [{ menuItemId: 1, size: 'large', quantity: 2 }],
}),
});
expect(res.status).toBe(201);
const json = await res.json();
expect(json.data.customerName).toBe('Alice');
});
it('returns 400 for invalid body', async () => {
const res = await app.request('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customerName: '' }),
});
expect(res.status).toBe(400);
const json = await res.json();
expect(json.error.code).toBe('VALIDATION_ERROR');
});
it('returns 404 for unknown route', async () => {
const res = await app.request('/api/nonexistent');
expect(res.status).toBe(404);
});
});Key points:
app.request(path, init?) returns a standard Response object. No external test library needed.RequestInit (method, headers, body).Request/Response.Each runtime needs a different entry point. Never mix them:
// === Node.js === (requires @hono/node-server)
import { serve } from '@hono/node-server';
serve({ fetch: app.fetch, port: 3000 });
// === Bun ===
export default {
fetch: app.fetch,
port: 3000,
};
// === Cloudflare Workers ===
export default app;
// === Deno ===
Deno.serve({ port: 3000 }, app.fetch);Key points:
@hono/node-server adapter package. export default app does not work on Node.export default { fetch, port }. Do not use @hono/node-server on Bun.export default app directly.app.ts) from the entry point (server.ts or index.ts) so the app can be imported for testing without starting a server.All errors must follow this shape:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body"
}
}For validation errors with field-level detail:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": [
{ "path": "customerName", "message": "String must contain at least 1 character(s)" },
{ "path": "items", "message": "Required" }
]
}
}zValidator plain-text 400 bodyEvery Hono app must have from the start:
Env generic with Variables (and Bindings for Cloudflare)app.route() and separate Hono instanceszValidator with a hook callback returning structured JSON errorsc.req.valid('json') in handlers (never c.req.json() after zValidator)logger, secureHeaders, corscreateMiddleware and await next()onError handler with structured { error: { code, message } } responsesnotFound handler returning JSON (not default plain text)app.request() (not supertest)