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/spec serves the generated OpenAPI JSON spec.
  • /openapi/ui displays an interactive API reference (Swagger UI via Scalar).
  • generateSwaggerDocs can produce a static openAPI.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