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