Skip to content

DOMAIN:BACKEND:HONO_FRAMEWORK

OWNER: urszula (Team Alfa), maxim (Team Bravo)
UPDATED: 2026-03-24
SCOPE: Hono framework patterns for all GE client projects
VERSION: Hono 4.x (latest stable)


HONO:WHY_HONO

OVER_EXPRESS

EXPRESS_PROBLEMS:
- No native TypeScript support — requires @types/express, types often outdated
- Callback-based middleware — error handling is inconsistent
- No built-in validation — requires express-validator or manual Zod wiring
- 2-3x slower than Hono under load
- No RPC client — requires manual client SDK or code generation
- Massive dependency tree — security surface area

HONO_ADVANTAGES:
- Written in TypeScript — types are first-class, not afterthought
- Web Standards-based — Request, Response, fetch API
- Built-in RPC client — end-to-end type safety without tRPC
- RegExpRouter — fastest JavaScript router (single regex match)
- ~14KB bundle — 50x smaller than Express
- Multi-runtime — Node.js, Deno, Bun, Cloudflare Workers, AWS Lambda
- Built-in middleware — CORS, auth, validation, compression, caching
- createMiddleware() with generics — type-safe context passing

OVER_FASTIFY

FASTIFY_STRENGTHS: mature plugin ecosystem, slightly faster on pure Node.js throughput
HONO_STRENGTHS: better TypeScript DX, RPC client, multi-runtime, smaller bundle, edge-ready
GE_DECISION: Hono for new projects, Fastify acceptable for projects needing specific Fastify plugins

PERFORMANCE_COMPARISON (Node.js benchmarks, 2025)

Metric Express Fastify Hono
Requests/sec (basic) ~15,000 ~30,000 ~25,000
Requests/sec (JSON) ~12,000 ~28,000 ~22,000
Memory usage baseline -20% -40%
Cold start ~150ms ~80ms ~30ms
Bundle size ~700KB ~300KB ~14KB

NOTE: Fastify leads on sustained Node.js throughput; Hono leads on memory efficiency and cold starts
NOTE: on Bun runtime, Hono outperforms both — but GE uses Node.js as standard runtime


HONO:PROJECT_SETUP

MINIMAL_SETUP

// src/index.ts
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { logger } from 'hono/logger';
import { secureHeaders } from 'hono/secure-headers';
import { errorHandler } from './middleware/error-handler';

const app = new Hono();

// Global middleware
app.use('*', logger());
app.use('*', secureHeaders());
app.onError(errorHandler);

// Health checks
app.get('/health', (c) => c.json({ status: 'ok' }));

// Routes
app.route('/api/v1/users', usersRoute);
app.route('/api/v1/projects', projectsRoute);

serve({ fetch: app.fetch, port: 3000 }, (info) => {
  console.log(`Server running on port ${info.port}`);
});

export default app;
export type AppType = typeof app;

PACKAGE_DEPENDENCIES

{
  "dependencies": {
    "hono": "^4.6.0",
    "@hono/node-server": "^1.13.0",
    "@hono/zod-validator": "^0.4.0",
    "zod": "^3.24.0",
    "drizzle-orm": "^0.38.0",
    "postgres": "^3.4.0",
    "pino": "^9.6.0",
    "ioredis": "^5.4.0"
  },
  "devDependencies": {
    "typescript": "^5.7.0",
    "vitest": "^3.0.0",
    "@hono/zod-openapi": "^0.18.0",
    "pino-pretty": "^13.0.0",
    "drizzle-kit": "^0.30.0"
  }
}

TSCONFIG

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist",
    "declaration": true,
    "sourceMap": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": false
  },
  "include": ["src"]
}

RULE: strict: true is non-negotiable
RULE: noUncheckedIndexedAccess: true catches array/object index bugs


HONO:ROUTING

ROUTE_ORGANIZATION

// src/routes/users.ts
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { createUserSchema, updateUserSchema, userParamsSchema } from '../schemas/user';
import { UserService } from '../services/user-service';

const app = new Hono()
  .get('/', async (c) => {
    const users = await UserService.list();
    return c.json({ success: true, data: users });
  })
  .get('/:id', zValidator('param', userParamsSchema), async (c) => {
    const { id } = c.req.valid('param');
    const user = await UserService.findById(id);
    if (!user) return c.json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } }, 404);
    return c.json({ success: true, data: user });
  })
  .post('/', zValidator('json', createUserSchema), async (c) => {
    const body = c.req.valid('json');
    const user = await UserService.create(body);
    return c.json({ success: true, data: user }, 201);
  })
  .put('/:id', zValidator('param', userParamsSchema), zValidator('json', updateUserSchema), async (c) => {
    const { id } = c.req.valid('param');
    const body = c.req.valid('json');
    const user = await UserService.update(id, body);
    return c.json({ success: true, data: user });
  });

export default app;

RULE: chain route methods for type inference — new Hono().get(...).post(...) not separate app.get()
WHY: chaining preserves the full type for RPC client inference
ANTI_PATTERN: defining routes with separate app.get(), app.post() calls and expecting RPC types to work
FIX: chain all routes on the Hono instance, export typeof app

GROUPED_ROUTES

// src/index.ts
import { Hono } from 'hono';
import users from './routes/users';
import projects from './routes/projects';
import tasks from './routes/tasks';

const app = new Hono()
  .route('/api/v1/users', users)
  .route('/api/v1/projects', projects)
  .route('/api/v1/tasks', tasks);

export default app;
export type AppType = typeof app;

HONO:MIDDLEWARE

CREATING_TYPED_MIDDLEWARE

// middleware/auth.ts
import { createMiddleware } from 'hono/factory';
import { HTTPException } from 'hono/http-exception';

type AuthEnv = {
  Variables: {
    userId: string;
    userRole: 'admin' | 'user' | 'viewer';
  };
};

export const authMiddleware = createMiddleware<AuthEnv>(async (c, next) => {
  const token = c.req.header('Authorization')?.replace('Bearer ', '');
  if (!token) throw new HTTPException(401, { message: 'Missing authorization' });

  const payload = await verifyToken(token);
  if (!payload) throw new HTTPException(401, { message: 'Invalid token' });

  c.set('userId', payload.sub);
  c.set('userRole', payload.role);

  await next();
});

// Usage in route — c.get('userId') is typed as string
app.use('/api/*', authMiddleware);
app.get('/api/me', (c) => {
  const userId = c.get('userId');  // string, not string | undefined
  return c.json({ userId });
});

PARAMETERIZED_MIDDLEWARE

// middleware/require-role.ts
import { createMiddleware } from 'hono/factory';
import { HTTPException } from 'hono/http-exception';

type UserRole = 'admin' | 'user' | 'viewer';

export const requireRole = (...roles: UserRole[]) =>
  createMiddleware(async (c, next) => {
    const userRole = c.get('userRole') as UserRole;
    if (!roles.includes(userRole)) {
      throw new HTTPException(403, { message: 'Insufficient permissions' });
    }
    await next();
  });

// Usage
app.delete('/api/v1/users/:id', requireRole('admin'), async (c) => {
  // Only admin can reach here
});

MIDDLEWARE_ORDER

RULE: middleware executes in registration order — put auth before business logic
RULE: error handler wraps everything via app.onError()
RULE: logging middleware goes first (captures timing for all requests)

EXECUTION_ORDER:
1. logger (timing start)
2. secureHeaders
3. cors
4. rateLimit
5. authMiddleware
6. requireRole (if applied)
7. zValidator (request validation)
8. handler (business logic)
9. logger (timing end)
10. errorHandler (if any step threw)

HONO:CONTEXT_AND_ENV

CENTRALIZED_ENV_TYPE

// types/env.ts
export type AppEnv = {
  Bindings: {
    DATABASE_URL: string;
    REDIS_URL: string;
    JWT_SECRET: string;
  };
  Variables: {
    requestId: string;
    userId: string;
    userRole: 'admin' | 'user' | 'viewer';
    logger: Logger;
  };
};

// Use with factory to avoid repeating env type
import { createFactory } from 'hono/factory';
const factory = createFactory<AppEnv>();

export const createApp = () => new Hono<AppEnv>();
export const createAppMiddleware = factory.createMiddleware;

REQUEST_ID_MIDDLEWARE

export const requestId = createMiddleware(async (c, next) => {
  const id = c.req.header('X-Request-Id') ?? crypto.randomUUID();
  c.set('requestId', id);
  c.header('X-Request-Id', id);
  await next();
});

HONO:VALIDATION_WITH_ZOD

MULTI_SOURCE_VALIDATION

// Validate params, query, and body in one route
app.post(
  '/projects/:projectId/tasks',
  zValidator('param', z.object({ projectId: z.string().uuid() })),
  zValidator('query', z.object({ notify: z.coerce.boolean().default(false) })),
  zValidator('json', z.object({
    title: z.string().min(1).max(500),
    assigneeId: z.string().uuid().optional(),
    priority: z.enum(['low', 'medium', 'high', 'critical']),
  })),
  async (c) => {
    const { projectId } = c.req.valid('param');
    const { notify } = c.req.valid('query');
    const body = c.req.valid('json');
    // All fully typed
  }
);

CUSTOM_VALIDATION_HOOK

// Return custom error format on validation failure
import { zValidator } from '@hono/zod-validator';

const validatorWithHook = (target: 'json' | 'query' | 'param', schema: z.ZodSchema) =>
  zValidator(target, schema, (result, c) => {
    if (!result.success) {
      return c.json({
        success: false,
        error: {
          code: 'VALIDATION_ERROR',
          message: `Invalid ${target}`,
          details: result.error.issues,
        },
      }, 400);
    }
  });

HONO:RPC_CLIENT

SETUP

// Server exports type
export type AppType = typeof app;

// Client uses type
import { hc } from 'hono/client';
import type { AppType } from '../server';

const client = hc<AppType>('http://localhost:3000');

// Fully typed calls
const res = await client.api.v1.users.$get();
const { data } = await res.json();  // data is User[]

const newUser = await client.api.v1.users.$post({
  json: { name: 'Alice', email: 'alice@example.com' },
});

TYPE_PERFORMANCE_TIP

CHECK: is IDE becoming slow with many Hono routes?
IF: yes THEN: use pre-computed type helper

// lib/api-client.ts
import { hc } from 'hono/client';
import type { AppType } from '../server';

// Pre-compute the type once — prevents repeated type instantiation
const apiClient = hc<AppType>('');
type ApiClient = typeof apiClient;

export const createApiClient = (baseUrl: string): ApiClient => hc<AppType>(baseUrl);

HONO:TESTING_WITH_VITEST

TESTING_APPROACH

RULE: test Hono apps using app.request() — no need for HTTP server
RULE: test middleware separately from handlers
RULE: use Vitest for all tests

// routes/users.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import app from './users';

describe('GET /users', () => {
  it('returns paginated users', async () => {
    const res = await app.request('/users?page=1&pageSize=10');
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.success).toBe(true);
    expect(body.data).toBeInstanceOf(Array);
    expect(body.meta.page).toBe(1);
  });

  it('returns 400 for invalid page', async () => {
    const res = await app.request('/users?page=-1');
    expect(res.status).toBe(400);

    const body = await res.json();
    expect(body.success).toBe(false);
    expect(body.error.code).toBe('VALIDATION_ERROR');
  });
});

describe('POST /users', () => {
  it('creates user with valid data', async () => {
    const res = await app.request('/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' }),
    });
    expect(res.status).toBe(201);
  });

  it('rejects missing required fields', async () => {
    const res = await app.request('/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({}),
    });
    expect(res.status).toBe(400);
  });
});

TESTING_MIDDLEWARE

// middleware/auth.test.ts
import { Hono } from 'hono';
import { authMiddleware } from './auth';

const createTestApp = () => {
  const app = new Hono();
  app.use('*', authMiddleware);
  app.get('/protected', (c) => c.json({ userId: c.get('userId') }));
  return app;
};

it('rejects request without token', async () => {
  const app = createTestApp();
  const res = await app.request('/protected');
  expect(res.status).toBe(401);
});

it('passes with valid token', async () => {
  const app = createTestApp();
  const res = await app.request('/protected', {
    headers: { Authorization: 'Bearer valid-test-token' },
  });
  expect(res.status).toBe(200);
  const body = await res.json();
  expect(body.userId).toBeDefined();
});

HONO:DEPLOYMENT_ON_NODEJS

NODE_SERVER_SETUP

import { serve } from '@hono/node-server';
import app from './app';

const port = parseInt(process.env.PORT ?? '3000', 10);

const server = serve({ fetch: app.fetch, port }, (info) => {
  console.log(`Server started on http://localhost:${info.port}`);
});

// Graceful shutdown
const shutdown = () => {
  console.log('Shutting down...');
  server.close(() => {
    console.log('Server closed');
    process.exit(0);
  });
  // Force exit after 10 seconds
  setTimeout(() => process.exit(1), 10000);
};

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

STATIC_FILES

import { serveStatic } from '@hono/node-server/serve-static';

app.use('/static/*', serveStatic({ root: './public' }));

HONO:COMMON_PATTERNS

DEPENDENCY_INJECTION_VIA_CONTEXT

// Instead of global singletons, inject via middleware
export const withServices = createMiddleware(async (c, next) => {
  c.set('db', getDatabase());
  c.set('redis', getRedisClient());
  c.set('logger', createChildLogger({ requestId: c.get('requestId') }));
  await next();
});

FILE_UPLOAD

app.post('/upload', async (c) => {
  const body = await c.req.parseBody();
  const file = body['file'];
  if (!(file instanceof File)) {
    return c.json({ success: false, error: { code: 'INVALID_FILE', message: 'No file provided' } }, 400);
  }
  // Validate file type and size before processing
  if (file.size > 10 * 1024 * 1024) {
    return c.json({ success: false, error: { code: 'FILE_TOO_LARGE', message: 'Max 10MB' } }, 400);
  }
  const buffer = await file.arrayBuffer();
  // Process buffer...
  return c.json({ success: true, data: { filename: file.name, size: file.size } });
});

HONO:ANTI_PATTERNS

ANTI_PATTERN: importing Express middleware into Hono
FIX: use Hono's built-in middleware or create native Hono middleware with createMiddleware()

ANTI_PATTERN: using c.text() for API responses instead of c.json()
FIX: always use c.json() — enables RPC type inference and content-type negotiation

ANTI_PATTERN: not chaining routes (breaks RPC type export)
FIX: new Hono().get(...).post(...) — chain on same instance

ANTI_PATTERN: creating controller classes with methods (Express/NestJS pattern)
FIX: use route files with chained handlers — Hono is functional, not OOP

ANTI_PATTERN: heavy computation in middleware (e.g., crypto operations)
FIX: move heavy work to route handlers or worker threads; middleware should be fast

ANTI_PATTERN: catching errors in every handler with try/catch
FIX: let errors propagate to app.onError() — centralized error handling

ANTI_PATTERN: using app.all('*', handler) for catch-all — masks routing bugs
FIX: use app.notFound() for 404 handling explicitly