Skip to main content

Command Palette

Search for a command to run...

Hono: Setting up the development environment

Updated
13 min read
Hono: Setting up the development environment
R

Somebody who likes to code

In the rapidly evolving backend ecosystem, developers are constantly searching for frameworks that balance performance, simplicity, and modern developer experience. While established frameworks like Express and Fastify dominate the Node.js landscape, a new contender, Hono, is quickly gaining attention for its minimalistic design and exceptional performance across multiple runtimes.

This article provides a technical overview of Hono, its architectural principles, and provides a step-by-step guide to get started with it.

What is Hono?

Hono is a modern, lightweight web framework designed for building high-performance APIs and web applications across multiple JavaScript runtimes. Created by Yusuke Wada in 2021, Hono (炎 - meaning "flame" in Japanese) has rapidly gained traction in the TypeScript ecosystem due to its exceptional speed, minimal footprint, and runtime-agnostic design.

Unlike traditional Node.js frameworks, Hono is runtime-agnostic. It runs seamlessly on:

This flexibility makes Hono an excellent choice for edge computing, serverless architectures, and high-throughput APIs where low latency and cold start performance are critical. At its core, Hono emphasizes three main principles:

  • Speed: Built for performance; minimal overhead compared to Express.

  • Simplicity: Small, readable, and predictable API inspired by frameworks like Express and Koa.

  • Type Safety: Full TypeScript support with static typing and schema validation tools.

Why Hono Is a Good Alternative Today?

Edge-Native Design

Hono was built with edge runtimes in mind. Unlike Express, which assumes a long-running Node.js process, Hono applications can deploy directly to Cloudflare Workers or Vercel Functions without any adjustments.

Web Standards Compliance

Hono relies on Web Standards APIs, which means:

  • Code written in Hono is more portable.

  • Developers learn transferable skills rather than framework-specific APIs.

  • Future runtime migrations require minimal refactoring.

  • The framework benefits from ongoing standards improvements.

Developer Experience

Developers coming from Express will find Hono intuitive. Its route handling and middleware system are similar, but optimized for modern runtimes.

Built-in TypeScript and Middleware Ecosystem

Hono provides excellent TypeScript support out of the box, making type-safe development natural. It also offers an expanding ecosystem of official and third-party middleware.

Prerequisites

Project Initialization

For simplicity, we will use Node.js as the runtime and npm as the package manager. Run the following commands:

npm create hono@latest node-hono-api -- --template nodejs --install --pm npm
cd node-hono-api

TypeScript

Hono uses TypeScript and includes a default tsconfig.json file, which we will update as follows:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "NodeNext",
    "strict": true,
    "sourceMap": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "types": ["node"],
    "jsx": "react-jsx",
    "jsxImportSource": "hono/jsx",
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "noUnusedParameters": true,
    "noUnusedLocals": true,
    "noFallthroughCasesInSwitch": true,
    "allowUnreachableCode": false,
    "outDir": "./dist",
    "noErrorTruncation": true,
    "noPropertyAccessFromIndexSignature": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "exclude": ["**/node_modules", "**/.*/", "dist"],
  "include": ["src/**/*", "tests/**/*"]
}

Let's dive into all the options:

  • rootDir: Specifies the root directory of our input files. TypeScript uses this to control the output directory structure. When files are compiled, their relative path from rootDir is preserved in the outDir. The default value is the longest common path of all non-declaration input files. Determines how the output structure is organized.

  • include: Specifies which files TypeScript should compile. It's an array of glob patterns that tells the compiler which source files to process. These filenames are resolved relative to the directory containing the tsconfig.json file. Determines what files to compile.

  • exclude: Specifies which should be skipped when resolving include. The remaining files are compiled. It's also resolved relative to the directory containing tsconfig.json.

  • outDir: Specifies the output directory where TypeScript places all compiled JavaScript files.

  • target: Specifies which version of JavaScript the TypeScript compiler should output. The special ESNext value refers to the highest version of TypeScript that our version supports.

  • module: Specifies which module system to use for organizing code (imports/exports). NodeNext tells TypeScript to use Node.js's native module resolution,

  • strict: Enables all strict type-checking options at once.

  • verbatimModuleSyntax: Requires us to use exact import/export syntax that matches our module system. Forces us to be explicit about type-only imports.

  • skipLibCheck: Skips type checking of declaration files (.d.ts files in node_modules).

  • types: By default, all visible @types packages are included in our compilation. Specifies which type definition packages to include from @types, preventing unnecessary type definitions from being loaded.

  • jsx: Specifies how JSX syntax should be transformed.

    • preserve: Keep JSX as-is (.jsx output).

    • react: Transform to React.createElement() calls (classic).

    • react-jsx: Modern transform (automatic runtime, no need to import React).

    • react-jsxdev: Development version with debugging info.

    • react-native - Preserves JSX syntax but outputs .js files instead of .jsx.

  • jsxImportSource: Specifies which package provides the JSX runtime.

  • sourceMap: Generates source map files (.js.map). These files allow debuggers and other tools to display the original TypeScript source code when actually working with the emitted JavaScript files.

  • forceConsistentCasingInFileNames: Enforces that file names in imports match the actual casing on disk.

  • resolveJsonModule: Allows us to import JSON files directly as modules with full type safety.

  • esModuleInterop: Allows default imports from CommonJS modules.

  • noUnusedParameters: Reports an error when function parameters are declared but never used. Parameters declaration with names starting with an underscore (_) are exempt from the unused parameter checking.

  • noUnusedLocals: Reports an error when local variables are declared but never used.

  • noFallthroughCasesInSwitch: Reports an error when a case in a switch statement falls through to the next case without a break, return, or throw.

  • allowUnreachableCode: Controls whether TypeScript reports errors for code that can never be executed.

  • noErrorTruncation: Prevents TypeScript from truncating long error messages.

  • noPropertyAccessFromIndexSignature: Forces you to use bracket notation obj['key'] instead of dot notation obj.key when accessing properties defined by index signatures.

  • noUncheckedIndexedAccess: Makes array/index access return potentially undefined, forcing you to handle missing values.

  • paths: Defines custom module path mappings for cleaner imports, like creating aliases for directories.

We are not explicitly defining the rootDir. Depending on which files we process, TypeScript will automatically set it up. For example:

Input files from include patterns:
├── src/index.ts
├── src/routes/api.ts
├── src/utils/helper.ts
├── tests/api.test.ts
├── tests/unit/user.test.ts
└── tests/integration/db.test.ts

The calculated rootDir will be ..

Input files from include pattern:
├── src/index.ts
├── src/routes/api.ts
└── src/utils/helper.ts

Calculated rootDir will be ./src.

TypeScript's compiler (tsc) converts our TypeScript code to JavaScript but doesn't handle path aliases in the output, which can lead to runtime errors. tsc-alias is a tool that resolves TypeScript path aliases in our compiled JavaScript output.

npm install --save-dev tsc-alias

Modify the default build script like this:

{
  ...
  "scripts": {
    ...
    "build": "tsc && tsc-alias",
    ...
  }
  ...
}

Code Formatter

We will use Prettier, a code formatter that keeps our project's style consistent. Run the following command to install it as a development dependency:

npm install --save-dev prettier

Create a prettier.config.ts file in the root of the project:

import { type Config } from "prettier";

const config: Config = {
  trailingComma: "es5",
  singleQuote: true,
  arrowParens: "avoid",
  endOfLine: "crlf",
};

export default config;
  • trailingComma: Add trailing commas where valid in ES5.

  • singleQuote: Use single quotes (') instead of double quotes (") for strings.

  • arrowParens: Omit parentheses when possible for single-parameter arrow functions.

  • endOfLine: Use CRLF line endings (for Windows users only).

TypeScript support requires Node.js>=22.6.0, and --experimental-strip-types is required before Node.js v24.3.0.

Create a .prettierignore file to exclude some files:

dist/
package-lock.json

By default, prettier ignores files in version control systems directories (".git", ".jj", ".sl", ".svn", and ".hg") and node_modules (unless the --with-node-modules CLI option is specified).

Add the following scripts to the package.json file:

{
  ...
  "scripts": {
    ...
    "format": "prettier --write .",
    "format:check": "prettier --check ."
    ...
  }
  ...
}

install the Prettier - Code formatter extension for a better development experience. Create a .vscode/settings.json file in the project root:

{
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.formatOnSave": true
}

This configuration sets Prettier as the default formatter and automatically formats the code on save.

Code Analyzer

ESLint is a static code analyzer that helps us identify problems in our code. Run the following command to install it:

npm install --save-dev jiti
npm install --save-dev eslint-config-prettier
npm init @eslint/config@latest

For Deno and Bun, TypeScript configuration files are natively supported; for Node.js, we must install the optional dev dependency jiti.

Follow the prompts to set up ESLint according to our preferences. In our case, we choose:

√ What do you want to lint? · javascript
√ How would you like to use ESLint? · problems
√ What type of modules does your project use? · esm
√ Which framework does your project use? · none
√ Does your project use TypeScript? · No / Yes
√ Where does your code run? · browser
√ Which language do you want your configuration file be written in? · ts
i The config that you've selected requires the following dependencies:

eslint, @eslint/js, globals, typescript-eslint
√ Would you like to install them now? · No / Yes
√ Which package manager do you want to use? · npm

This will create an eslint.config.ts file with a basic configuration that we will replace as follows:

import jseslint from "@eslint/js";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
import prettierConfig from 'eslint-config-prettier';

export default defineConfig(
  globalIgnores(["dist/**/*"]),
  jseslint.configs.recommended,
  tseslint.configs.recommended,
  prettierConfig
);
  • jseslint.configs.recommended: Enables recommended JavaScript linting rules.

  • tseslint.configs.recommended: Enables recommended TypeScript linting rules.

  • prettierConfig: Disables all previous ESLint formatting rules that conflict with Prettier.

  • globalIgnores: Ignores all files in the dist directory.

Add the following scripts to the package.json file:

{
  ...
  "scripts": {
    ...
    "lint": "eslint .",
    "lint:fix": "eslint . --fix"
    "lint:format": "npm run lint:fix && npm run format"
    ...
  }
  ...
}

Install the ESLint extension for a better development experience. Update the .vscode/settings.json file with the following content:

{
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit"
  }
}

Environment Variables

To work with environment variables, we will use the following packages:

  • Dotenv: Reads .env files and loads them into process.env.

  • Dotenv-expand: Allows referencing other environment variables within .env.

  • Cross-env: Sets environment variables that work on all operating systems.

  • Zod: Generates TypeScript types from schemas and validates data at runtime, not just at compile time.

npm install dotenv dotenv-expand cross-env zod

Create a .env file in the project root:

PORT=5000

Create the /src/env.ts file with the following content:

import { config } from "dotenv";
import { expand } from "dotenv-expand";
import { ZodError, z } from "zod";

const ENVSchema = z.object({
  NODE_ENV: z
    .enum(["development", "production", "test"])
    .default("development"),
  PORT: z.coerce.number().default(3000),
});

expand(config());

try {
  ENVSchema.parse(process.env);
} catch (error) {
  if (error instanceof ZodError) {
    const e = new Error(
      `Environment validation failed:\n ${z.treeifyError(error)}`,
    );
    e.stack = "";
    throw e;
  } else {
    console.error("Unexpected error during environment validation:", error);
    throw error;
  }
}

export const ENV = ENVSchema.parse(process.env);

This file validates and loads environment variables using Zod for type safety. What does it do?

  • Loads .env file:

    • config() loads variables from the .env file.

    • expand() resolves variable references like ${OTHER_VAR}.

  • Defines expected environment variables:

    • NODE_ENV must be one of: development, production, or test (defaults to development).

    • PORT must be a number (coerced from string, defaults to 3000).

  • Validates environment variables:

    • Checks process.env against the schema.

    • Throws a readable error if validation fails.

This pattern ensures our app fails fast with clear errors if the environment configuration is wrong. Replace the index.ts file with the following content:

import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { ENV } from "@/env.js";

const app = new Hono();

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

serve(
  {
    fetch: app.fetch,
    port: ENV.PORT,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  },
);

Modify the following scripts in the package.json file:

{
  ...
  "scripts": {
    ...
    "dev": "cross-env NODE_ENV=development tsx watch src/index.ts",
    ...
    "start": "cross-env NODE_ENV=development node dist/index.js",
    ...
  }
  ...
}

Debugging

Create a .vscode/launch.json file with the following content:

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Debug TypeScript",
      "program": "${workspaceFolder}/dist/index.js",
      "preLaunchTask": "npm: build",
      "outFiles": ["${workspaceFolder}/dist/**/*.js"],
      "sourceMaps": true
    }
  ]
}

This is a VS Code debugger configuration for debugging our TypeScript Node.js application.

  • "type": "node": Specifies this is a Node.js application debugger.

  • "request": "launch": Launches a new Node.js process.

  • "name": "Debug TypeScript": The name that appears in VS Code's debug dropdown.

  • "program": "${workspaceFolder}/dist/index.js": The entry point file to run. Points to compiled JavaScript in dist, not your TypeScript source.

  • "preLaunchTask": "npm: build": Runs npm run build before debugging starts. Ensures TypeScript is compiled to JavaScript first.

  • "outFiles": ["${workspaceFolder}/dist/**/*.js"]: Tells debugger where compiled JavaScript files are located. Used for mapping breakpoints

  • "sourceMaps": true: Enables source map support. Let's us debug TypeScript code instead of compiled JavaScript. Works because our tsconfig.json has "sourceMap": true.

Press F5 or click Debug TypeScript in the Run and Debug panel.

Ensure the program property matches our app's entry point. With our current TypeScript setup, when a test is implemented, the new value must be changed to ${workspaceFolder}/dist/src/index.js.

Git Hooks

Husky allows you to run scripts before commits and pushes via git hooks, ensuring code quality standards are maintained automatically. Install it by running the following command:

npm install --save-dev husky

Initialize Husky:

npx husky init

The init command simplifies setting up Husky in a project. It creates a pre-commit script in .husky/ and updates the prepare script in package.json.

Edit the .husky/pre-commit file to run Prettier and ESLint:

npm run lint
npm run format:check

Husky will modify the package.json file by adding the following script:

{
  ...
  "scripts": {
    ...
    "prepare": "husky"
    ...
  }
  ...
}

Commit Messages

Commitlint is a tool that validates commit messages to ensure they follow a consistent format and conventional standards. Install it by running the following command:

npm install --save-dev @commitlint/config-conventional @commitlint/cli @commitlint/prompt-cli

Create the commitlint.config.ts file in the project root:

import type { UserConfig } from '@commitlint/types';

const Configuration: UserConfig = {
  extends: ['@commitlint/config-conventional'],
};

export default Configuration;

This configuration sets up commit message linting for our project. The @commitlint/config-conventional package implements the Conventional Commits specification. The commit message should be structured as follows:

type(scope?): subject
body?
footer?

Create the .husky/commit-msg file with the following content:

npx --no-install commitlint --edit $1

The @commitlint/prompt-cli package helps us create commit messages that follow the commit convention set in the commitlint.config.js file. To make prompt-cli easy to use, add a new script to the package.json file:

{
  ...
  "scripts": {
    ...
    "commit": "commit"
    ...
  }
  ...
}

"prepare" is a special npm lifecycle script that runs automatically at specific times:

  • After npm install (when someone clones our repo and installs dependencies).

  • Before npm publish (when publishing a package).

In our project, we will run the husky command to set up Git hooks in the .husky folder.

Visual Studio Code Optimizations

Open the .vscode/settings.json file and update the content as follows:

{
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit"
  },
  "search.exclude": {
    "**/node_modules": true,
    "**/dist": true,
    "**/.git": true
  },
  "files.exclude": {
    "**/node_modules": true
  },
  "files.watcherExclude": {
    "**/node_modules/**": true,
    "**/dist/**": true,
    "**/.git/**": true
  },
  "typescript.tsserver.maxTsServerMemory": 4096,
  "typescript.tsserver.enableTracing": false,
  "editor.minimap.enabled": false
}
  • search.exclude: Excludes irrelevant directories from search results.

  • files.exclude: Hides node_modules from the sidebar.

  • files.watcherExclude: Stops watching files.

  • typescript.tsserver.maxTsServerMemory: Modifies TS server memory

  • typescript.tsserver.enableTracing: Reduces overhead from TypeScript debugging.

  • editor.minimap.enabled: Removes the code minimap for more space.

Now we are ready to start working on our app. In future articles, we will review many Hono features, so stay tuned. You can find all the code here. Thanks, and happy coding.

More from this blog

raulnq

171 posts

Somebody who likes to code