Hono, swagger, Pino and Node.js
A step-by-step guide to setting up a fast, modern API using Hono on Node.js.
September 23, 2025
You can see the full source code on GitHub.
Setup folder
pnpm create hono@latest 01.hono-swagger-pino
# create-hono version 0.19.2
# ✔ Using target directory … 01.hono-swagger-pino
# ✔ Which template do you want to use? nodejs
# ✔ Do you want to install project dependencies? Yes
# ✔ Which package manager do you want to use? yarn
# ✔ Cloning the template
# ✔ Installing project dependencies
# 🎉 Copied project files
# Get started with: cd 01.hono-swagger-pino
cd 01.hono-swagger-pino
pnpm install hono-openapi @hono/standard-validator zod
We use a feature-based structure: each domain (here users) contains its own controller, routes, schemas, and services.
This avoids scattering code into global controllers/, services/, etc. directories and keeps each feature modular.
The structure looks like this:
├── package.json
├── src
│ ├── app.ts
│ ├── factories
│ │ └── logger.factories.ts
│ ├── features
│ │ └── users
│ │ ├── users.controller.ts
│ │ ├── users.route.ts
│ │ ├── users.schema.ts
│ │ ├── users.service.ts
│ │ └── users.type.ts
│ ├── index.ts
│ ├── middlewares
│ │ └── pino.middleware.ts
│ └── utils
│ ├── asyncLocalStorage.ts
│ ├── error.schema.ts
│ └── logger.util.ts
│ └── openAPI.util.ts
Creating factories
We create a Hono factory with custom typings (LoggerBindings).
This lets us build sub-apps (controllers) with an enriched context by default, for example including a logger.
The main benefit: we avoid repeating boilerplate configuration inside every feature.
src/factories/logger.factories.ts
import { createFactory } from 'hono/factory';
import type { PinoLogger } from 'hono-pino';
export type LoggerBindings = {
Variables: {
logger: PinoLogger;
};
};
export const createLoggerFactory = createFactory<LoggerBindings>();
Creating middleware and asyncLocalStorage
logger.util.ts centralizes the Pino configuration (level, serializers, pretty printing).
src/utils/logger.util.ts
import pino from 'pino';
import pretty from 'pino-pretty';
const createPinoConfig = (): pino.LoggerOptions => {
return {
level: 'info',
serializers: {
err: pino.stdSerializers.err,
req() {
return {
reqId: crypto.randomUUID(),
};
},
},
};
};
export const createLogger = (): pino.Logger => {
const options = createPinoConfig();
if (process.env.NODE_ENV === 'production') return pino(options);
return pino(options, pretty());
};
asyncLocalStorage.ts provides a container to propagate the logger across the stack (even inside services that don’t have direct access to c).
→ This means you can call getLoggerStore() anywhere in your services without passing the logger around manually.
src/utils/asyncLocalStorage.ts
import { AsyncLocalStorage } from 'node:async_hooks';
import type { PinoLogger } from 'hono-pino';
export const loggerStorage = new AsyncLocalStorage<PinoLogger>();
export const getLoggerStore = () => {
const logger = loggerStorage.getStore();
if (!logger) {
throw new Error('Logger not found in AsyncLocalStorage');
}
return logger;
};
We wire everything in a middleware.
src/middlewares/pino.middleware.ts
import { pinoLogger } from 'hono-pino';
import { createLoggerFactory } from '../factories/logger.factories.js';
import { loggerStorage } from '../utils/asyncLocalStorage.js';
import { createLogger } from '../utils/logger.util.js';
export const loggerMiddleware = createLoggerFactory.createMiddleware(async (c, next) => {
const basePinoLogger = pinoLogger({ pino: createLogger() });
return basePinoLogger(c, async () => {
const logger = c.get('logger');
return loggerStorage.run(logger, async () => {
return await next();
});
});
});
Creating features
For demonstration, we build a users feature. It shows how to wire services, routes, schemas, and controllers together. The users service works with an in-memory array and accepts a simple payload with three fields: id, name, and age.
Service: business logic (here managing the in-memory users array).
src/features/users/users.service.ts
import { getLoggerStore } from '../../utils/asyncLocalStorage.js';
const users = [
{ id: '1', name: 'John Doe', age: 30 },
{ id: '2', name: 'Jane Smith', age: 25 },
];
export const getUser = (id: string) => users.find((user) => user.id === id);
export const getUsers = () => users;
export const createUser = (name: string, age: number) => {
const newUser = { id: (users.length + 1).toString(), name, age };
const logger = getLoggerStore();
logger.info({ user: newUser }, 'Creating new user from service');
users.push(newUser);
return newUser;
};
Schema: validation and typing with zod.
src/features/users/users.schema.ts
import { z } from 'zod';
export const UserSchema = z.object({
id: z.string(),
name: z.string(),
age: z.number(),
});
export const UsersSchema = z.array(UserSchema);
export const ErrorSchema = z.object({
message: z.string(),
cause: z
.object({
code: z.string().optional(),
message: z.string().optional(),
stack: z.string().optional(),
})
.optional(),
});
export const CreateUserSchema = z.object({
name: z.string().min(1, { message: 'Name is required' }),
age: z.number().min(0, { message: 'Age must be a positive number' }),
});
We return consistent 400 errors when the input is invalid with Zod.
src/utils/error.schema.ts
import { z } from 'zod';
const ZodIssueSchema = z.object({
code: z.string(),
path: z.array(z.union([z.string(), z.number()])),
message: z.string(),
expected: z.any().optional(),
received: z.any().optional(),
minimum: z.number().optional(),
maximum: z.number().optional(),
inclusive: z.boolean().optional(),
multipleOf: z.number().optional(),
unionErrors: z.array(z.unknown()).optional(),
});
export const ZodErrorSchema = z.object({
issues: z.array(ZodIssueSchema),
name: z.string(),
});
export const ZodSafeParseErrorSchema = z.object({
error: ZodErrorSchema,
success: z.boolean(),
});
Route: OpenAPI documentation (describing inputs and outputs).
src/features/users/users.route.ts
import { describeRoute, resolver } from 'hono-openapi';
import { ZodSafeParseErrorSchema } from '../../utils/error.schema.js';
import { ErrorSchema, UserSchema, UsersSchema } from './users.schema.js';
export const getUserRoute = describeRoute({
summary: 'Retrieve a user by ID',
tags: ['Users'],
responses: {
200: {
description: 'Retrieve user by ID',
content: {
'application/json': {
schema: resolver(UserSchema),
},
},
},
400: {
description: 'Error',
content: {
'application/json': {
schema: resolver(ErrorSchema),
},
},
},
},
});
export const getUsersRoute = describeRoute({
summary: 'Retrieve all users',
tags: ['Users'],
responses: {
200: {
description: 'Retrieve users',
content: {
'application/json': {
schema: resolver(UsersSchema),
},
},
},
},
});
export const postUserRoute = describeRoute({
summary: 'Create a new user',
tags: ['Users'],
responses: {
201: {
description: 'User created successfully',
content: {
'application/json': {
schema: resolver(UserSchema),
},
},
},
400: {
description: 'Invalid input',
content: {
'application/json': {
schema: resolver(ZodSafeParseErrorSchema),
},
},
},
},
});
Controller: ties together routes, schemas, and services through Hono.
src/features/users/users.controller.ts
import { validator as zValidator } from "hono-openapi";
import { createLoggerFactory } from "../../factories/logger.factories.js";
import { getUserRoute, getUsersRoute, postUserRoute } from "./users.route.js";
import { CreateUserSchema } from "./users.schema.js";
import { createUser, getUser, getUsers } from "./users.service.js";
const app = createLoggerFactory
.createApp()
.get("/:id", getUserRoute, (c) => {
const id = c.req.param("id");
const user = getUser(id);
if (!user) {
return c.json({ message: "User not found" }, 404);
}
return c.json(user);
})
.get("/", getUsersRoute, (c) => {
const users = getUsers();
return c.json(users);
})
.post("/", postUserRoute, zValidator("json", CreateUserSchema), async (c) => {
const { name, age } = await c.req.valid("json");
const newUser = createUser(name, age);
const logger = c.get("logger");
logger.info({ user: newUser }, "Creating new user from controller");
return c.json(newUser, 201);
});
export default app;
OpenAPI configuration
openAPI.util.ts encapsulates the Swagger/Scalar setup:
/openapi/specserves the generated OpenAPI JSON spec./openapi/uidisplays an interactive API reference (Swagger UI via Scalar).generateSwaggerDocscan produce a staticopenAPI.json, useful for CI/CD or sharing API docs externally.
src/utils/openAPI.util.ts
import fs from 'node:fs';
import { Scalar } from '@scalar/hono-api-reference';
import type { Hono } from 'hono';
import { generateSpecs, openAPIRouteHandler } from 'hono-openapi';
export function setupOpenAPI<T extends { Variables: Record<string, unknown> }>(app: Hono<T>) {
app.get(
'/openapi/spec',
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'Swagger',
version: '1.0.0',
description: 'Swagger API',
},
},
}),
);
app.get(
'/openapi/ui',
Scalar({
theme: 'deepSpace',
url: `/openapi/spec`,
}),
);
}
// if you want a way to export json
export async function generateSwaggerDocs<T extends { Variables: Record<string, unknown> }>(
app: Hono<T>,
) {
setupOpenAPI(app);
const spec = await generateSpecs(app);
const pathToSpec = './src/openAPI.json';
fs.writeFileSync(pathToSpec, JSON.stringify(spec, null, 2));
}
we create a function generateSwaggerDocs to generate openAPI.json if needed
Entrypoint
app.ts composes middlewares and mounts the feature routes.
src/app.ts
import { Hono } from 'hono';
import usersController from './features/users/users.controller.js';
import { loggerMiddleware } from './middlewares/pino.middleware.js';
const app = new Hono().use(loggerMiddleware).route('/users', usersController);
export default app;
index.ts boots the Node server using @hono/node-server and calls generateSwaggerDocs.
src/index.ts
import { serve } from '@hono/node-server';
import app from './app.js';
import { setupOpenAPI } from './utils/openApi.util.ts';
setupOpenAPI(app);
serve(
{
fetch: app.fetch,
port: 3000,
},
(info) => {
console.log(`Server is running on http://localhost:${info.port}`);
},
);
Testing
We can now test our endpoints.
curl http://localhost:3000/users
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name":"Alice","age":28}'
curl http://localhost:3000/users/1