CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/hono-best-practices

Production Hono patterns — zValidator hooks, typed generics, error handling, middleware composition, testing, and multi-runtime deployment

87

2.57x
Quality

80%

Does it follow best practices?

Impact

98%

2.57x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/hono-best-practices/

name:
hono-best-practices
description:
Production Hono patterns that must be included from day one in any Hono API. Apply whenever building, extending, or scaffolding a Hono backend -- even if the task only asks for routes or CRUD endpoints. These patterns prevent untyped validation errors, leaked internals, broken middleware composition, and runtime-specific deployment bugs.
keywords:
hono, hono middleware, hono validator, hono error handling, cloudflare workers, edge runtime, hono zod, hono typescript, bun server, deno server, hono routing, hono context, hono rpc, hono testing, hono cors, hono api, hono app, hono rest api, hono crud, hono endpoint, hono route, build hono, create hono, new hono, hono setup, hono scaffold, hono project
license:
MIT

Hono Best Practices

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.


Why This Matters From Day One

Hono is a lightweight, multi-runtime framework. Without these patterns from the start:

  • Validation errors return plain text -- 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.
  • Custom middleware loses type safety -- Using c.set() / c.get() without declaring Variables in the generic means runtime crashes on typos and no IDE autocompletion.
  • Cloudflare bindings silently undefined -- Accessing c.env.DATABASE without a Bindings type means c.env is any and typos in binding names fail silently at runtime.
  • RPC type inference breaks -- Defining routes with separate .get() calls instead of method chaining on a single Hono instance prevents hc from inferring the full route type.
  • Wrong runtime entry point -- Using 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.


The Patterns

1. Route Structure with Typed Generics

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:

  • Pass the same 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.
  • For Cloudflare Workers, always declare Bindings in the generic -- otherwise c.env is untyped and binding name typos fail silently at runtime.

2. Validation with zValidator -- Always Provide a Hook

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:

  • The 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.
  • Use 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.
  • You can validate 'json', 'query', 'param', 'header', 'cookie', and 'form' targets.

3. Middleware -- Built-in and Custom

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:

  • Always await next() in middleware. Forgetting await causes downstream middleware and the response to execute out of order.
  • Use createMiddleware<Env>() from hono/factory to get typed c.set()/c.get() in custom middleware. Plain async (c, next) => {} functions lose the generic.
  • Register middleware before the routes it should apply to. Order matters.
  • Use path patterns: '*' for all routes, '/api/*' for a prefix.

4. Error Handling

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.
  • Extend HTTPException for typed errors. Use super(status, { message }).
  • Never leak stack traces or raw error messages in responses. Log them server-side, return a generic message to clients.
  • Error responses must use a consistent shape: { error: { code, message } }.

5. Testing with app.request()

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.
  • The second argument is a standard RequestInit (method, headers, body).
  • Works identically across all runtimes since it uses the Web Standards Request/Response.

6. Multi-Runtime Entry Points

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:

  • Node.js requires the @hono/node-server adapter package. export default app does not work on Node.
  • Bun uses export default { fetch, port }. Do not use @hono/node-server on Bun.
  • Cloudflare Workers uses export default app directly.
  • Always separate the app definition (app.ts) from the entry point (server.ts or index.ts) so the app can be imported for testing without starting a server.

Error Response Format

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" }
    ]
  }
}

Never:

  • Return the default zValidator plain-text 400 body
  • Return stack traces in any environment
  • Return different shapes from different routes
  • Return errors as 200 responses

Checklist

Every Hono app must have from the start:

  • App-level Env generic with Variables (and Bindings for Cloudflare)
  • Routes grouped with app.route() and separate Hono instances
  • zValidator with a hook callback returning structured JSON errors
  • c.req.valid('json') in handlers (never c.req.json() after zValidator)
  • Built-in middleware: logger, secureHeaders, cors
  • Custom middleware uses createMiddleware and await next()
  • Global onError handler with structured { error: { code, message } } responses
  • notFound handler returning JSON (not default plain text)
  • No stack traces or raw error messages leaked to clients
  • Runtime-appropriate entry point (node-server / export default / Deno.serve)
  • App definition separated from entry point for testability
  • Tests use app.request() (not supertest)

Verifiers

  • zod-validator-hook -- zValidator must include a hook for JSON error responses
  • error-handling -- Global onError and notFound handlers with structured responses
  • middleware-composition -- Correct middleware patterns with await next() and createMiddleware
  • typed-env-generics -- App-level Env generic with Variables and Bindings
  • testing-patterns -- Use app.request() for testing, not supertest
  • runtime-entry-point -- Correct entry point for the target runtime

skills

hono-best-practices

tile.json