As a dev, you’d probably like to write your application once and not have to worry so much about where it's going to run. This is what the open source framework Hono lets you do, and it’s a game-changer. Hono is a small, incredibly fast web framework that embraces the "write once, run anywhere" philosophy.
The JavaScript ecosystem moves quickly. One minute, we're building monolithic Node.js servers. The next, it's all about serverless functions and running code at the edge on platforms like Cloudflare or Vercel. Staying current can feel like a full-time job.
Hono is built on top of Web Standards – the same Request
and Response
objects in your browser – which means your code is naturally portable across almost any JavaScript runtime.
This guide is a deep dive into this powerful little framework, designed to help you build real, production-ready applications. We’ll skip the quick "Hello, World!" and jump straight into the patterns and features you will actually use, with plenty of detailed code examples along the way.
Table of Contents
What You Will Learn in This Guide
By the end of this tutorial, you will be able to:
Structure a Hono project for both development and production.
Implement advanced routing patterns.
Leverage the full power of the Context object to manage requests and pass data between middleware.
Write complex custom middleware for authentication, logging, and error handling.
Validate incoming data using the official Zod validator for robust APIs.
Build a small, server-rendered application with JSX components.
Deploy a Hono application to various modern hosting platforms.
Prerequisites for Following Along
This is an in-depth guide, but it assumes you have some foundational knowledge. Before you start, you should have:
Node.js installed: Version 18 or higher is recommended.
A code editor: Visual Studio Code is a great choice.
Familiarity with TypeScript: You should understand basic types, functions, and
async
/await
.Basic command-line knowledge: You should be comfortable running commands in your terminal.
How to Set Up a Professional Hono Project
You can get started with Hono using a single command. This will create a new project directory with a recommended structure and configuration files. When prompted, select the nodejs
template and choose to install dependencies with your preferred package manager (for example, npm).
npm create hono@latest hono-production-app
The command will guide you through the setup:
> npx create-hono hono-production-app
create-hono version 0.19.2
✔ Using target directory … hono-production-app
✔ Which template do you want to use? nodejs
✔ Do you want to install project dependencies? Yes
✔ Which package manager do you want to use? npm
✔ Cloning the template
✔ Installing project dependencies
🎉 Copied project files
Get started with: cd hono-production-app
Now, navigate into your new directory: cd hono-production-app
. Let's look at the files that were created:
package.json
: Defines your project's dependencies and scripts.tsconfig.json
: The TypeScript configuration file.src/index.ts
: The entry point of your application.
Now, you can run npm run dev
to start your development server. Navigate to http://localhost:3000
, and you will see "Hello Hono!".
How to Understand Hono's Core API
Hono's API is designed to be minimal, which makes it easy to learn – yet incredibly powerful.
How to Use Advanced Routing Techniques
You may already know app.get()
and app.post()
from Express, but Hono's router can do much more.
1. How to Route with Regular Expressions
You can constrain a URL parameter to match a specific regular expression. For example, to make sure an :id
parameter only accepts numbers, you can do this:
// Only match routes like /users/123, not /users/abc
app.get('/users/:id{[0-9]+}', (c) => {
const id = c.req.param('id')
return c.text(`Fetching data for user ID: ${id}`)
})
2. How to Use Optional and Wildcard Routes
You can define routes that match multiple paths using wildcards (*
) or handle optional parameters.
// This will match /files/image.png, /files/docs/report.pdf, and so on.
app.get('/files/*', (c) => {
// c.req.path will contain the full matched path
return c.text(`You are accessing the file at: ${c.req.path}`)
})
// The '?' makes the '/:format?' part of the URL optional
// This will match both /api/posts and /api/posts/json
app.get('/api/posts/:format?', (c) => {
const format = c.req.param('format')
if (format === 'json') {
return c.json({ message: 'Here are the posts in JSON format.' })
}
return c.text('Here are the posts in plain text.')
})
3. How to Group Routes with app.route()
For larger applications, you should organize your routes into logical groups. The app.route()
method is perfect for this. It allows you to create modular routers and mount them on a specific prefix.
Let's create a more complex API structure for a blog.
src/routes/posts.ts
import { Hono } from 'hono'
// Create a new router instance specifically for posts
const posts = new Hono()
posts.get('/', (c) => c.json({ posts: [] }))
posts.post('/', (c) => c.json({ message: 'Post created' }, 201))
posts.get('/:id', (c) => c.json({ post: { id: c.req.param('id') } }))
export default posts
src/routes/authors.ts
import { Hono } from 'hono'
const authors = new Hono()
authors.get('/', (c) => c.json({ authors: [] }))
authors.get('/:id', (c) => c.json({ author: { id: c.req.param('id') } }))
export default authors
src/index.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { appendTrailingSlash } from 'hono/trailing-slash';
import posts from './routes/posts.js'
import authors from './routes/authors.js'
const app = new Hono()
app.use(appendTrailingSlash());
app.route('/posts/', posts)
app.route('/authors/', authors)
app.get('/', (c) => {
return c.text('Hello Hono!')
})
serve({
fetch: app.fetch,
port: 3000
}, (info) => {
console.log(`Server is running on http://localhost:${info.port}`)
})
This pattern keeps your main index.ts
file clean and makes your application much easier to navigate and maintain.
The Context Object in Depth
The Context (c
) is the heart of Hono. It's an object that gets passed to every middleware and route handler, containing all the information related to the current request. It's essentially a container for the request (c.req
) methods for creating a response (c.json
, c.html
, c.text
), as well as a special property for passing data between middleware (c.set
and c.get
).
While this covers its most common and useful properties, the full Context object contains more. For a comprehensive list of all available properties and methods, you can refer to the official Hono documentation.
Let's explore how you can use the context object to pass data between middleware and handlers, a crucial technique for things like authentication.
The c.set()
and c.get()
methods allow you to store and retrieve typed data within the context of a single request.
Replace src/index.ts
with this example for authentication:
import { Hono } from 'hono'
import type { Context, Next } from 'hono'
// Define a type for the variables we will store in the context
type AppVariables = {
user: {
id: string
name: string
roles: string[]
}
}
// Use a generic to tell our Hono app about the variables type
const app = new Hono<{ Variables: AppVariables }>()
// Middleware to "authenticate" a user from a header
const authMiddleware = async (c: Context, next: Next) => {
const userId = c.req.header('X-User-ID')
if (!userId) {
return c.json({ error: 'Missing X-User-ID header' }, 401)
}
// In a real app, you would fetch this from a database
const user = {
id: userId,
name: 'Jane Doe',
roles: ['admin', 'editor'],
}
// Use c.set() to attach the user data to the context
c.set('user', user)
await next()
}
app.get('/admin/dashboard', authMiddleware, (c) => {
// Use c.get() to retrieve the typed user data
const user = c.get('user')
if (!user.roles.includes('admin')) {
return c.json({ error: 'Forbidden' }, 403)
}
return c.json({
message: `Welcome to the admin dashboard, ${user.name}!`,
userId: user.id,
})
})
export default app
Let's break down the important parts of the code above.
Typed context variables: We define a TypeScript type
AppVariables
and pass it as a generic to our Hono appnew Hono<{ Variables: AppVariables }>()
. This is a powerful feature that gives us full type-safety for our context variables, preventing typos and ensuring that the data we store and retrieve is exactly what we expect it to be.Custom middleware: The
authMiddleware
is a custom function that runs before our route handler. It inspects the incoming request's headers (c.req.header('X-User-ID')
).Storing data: If a valid header is found, the middleware uses
c.set('user', user)
to store the user object on the context. This data is now available to any subsequent middleware or route handler for the same request.Retrieving data: The route handler
app.get('/admin/dashboard', ...)
then usesc.get('user')
to retrieve the user object. Hono's type system ensures thatc.get('user')
returns a variable with the type{ id: string; name: string; roles: string[]; }
.Flow control: If the user is missing or doesn't have the "admin" role, the middleware or handler can immediately send an error response using
c.json()
and a status code, preventing the request from proceeding further.
Now, run npm run dev
.
You can test with curl
(add header):
curl -H "X-User-ID: 123" http://localhost:3000/admin/dashboard
This will return a welcome message.
Without the header:
curl http://localhost:3000/admin/dashboard
This will return a 401
error.
This demonstrates how to pass typed data securely and efficiently between middleware and route handlers.
How to Use Advanced Features for Production Apps
Now we're ready to tackle the features you'll use every day in production: advanced middleware, data validation, and building full-stack applications.
How to Use Advanced Middleware Patterns
Hono has a powerful set of built-in middleware, including JWT and caching. These are not separate libraries you have to install, but rather functions that come with the Hono package itself.
Step 1: Replace src/index.ts
with this example for JWT and caching:
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { jwt, sign } from 'hono/jwt'
const app = new Hono()
const SECRET = 'my-secret-key' // Use an environment variable in production!
// Create a simple in-memory cache store
const cacheStore = new Map();
// Custom caching middleware for Node.js
app.use('/api/public-data', async (c, next) => {
const cacheKey = c.req.url;
// Check if the response is in our cache
if (cacheStore.has(cacheKey)) {
const cachedItem = cacheStore.get(cacheKey);
console.log('Serving from custom in-memory cache.');
return new Response(cachedItem.body, { headers: cachedItem.headers });
}
// If not in cache, proceed to the route handler
await next();
// After the handler returns, clone and store the response
if (c.res) {
const newResponse = c.res.clone();
const body = await newResponse.text();
const headers = Object.fromEntries(newResponse.headers.entries());
cacheStore.set(cacheKey, { body, headers });
console.log('Storing response in custom in-memory cache.');
}
});
// Login to get a JWT
app.post('/login', async (c) => {
const { username } = await c.req.json()
if (username === 'admin') {
const payload = {
sub: username,
role: 'admin',
exp: Math.floor(Date.now() / 1000) + 60 * 5, // 5 minutes expiration
}
const token = await sign(payload, SECRET)
return c.json({ token })
}
return c.json({ error: 'Invalid credentials' }, 401)
})
// Protected route
app.get(
'/api/protected',
jwt({ secret: SECRET }),
(c) => {
const payload = c.get('jwtPayload')
return c.json({ message: 'You have access!', payload })
}
)
// Cached route
app.get(
'/api/public-data',
async (c) => {
console.log('Executing handler with delay...');
await new Promise(resolve => setTimeout(resolve, 1000)) // Simulate a delay
return c.json({ data: 'This is some public data that rarely changes.' })
}
)
serve({ fetch: app.fetch, port: 3000 }, (info) => {
console.log(`Server is running on http://localhost:${info.port}`)
})
The code above shows two different types of middleware in action.
First, JWT middleware (jwt
) is a powerful way to secure your routes. When we call jwt({ secret: SECRET })
, we're telling Hono to check for a valid JWT in the Authorization
header of the incoming request. If a valid token is found, it decodes the payload and attaches it to the context, where we can retrieve it with c.get('jwtPayload')
. If no token is found or if the token is invalid, the middleware automatically stops the request and returns a 401 Unauthorized
error.
We also have Custom Cache Middleware which demonstrates the power of Hono's middleware system for in-memory caching. The middleware first checks an in-memory Map
to see if a response for the current URL already exists. If it does, it immediately returns the cached response, preventing the route handler from ever being executed. If the response is not in the cache, it allows the request to continue to the handler. After the handler returns, the middleware intercepts the response and stores a copy in the cache before sending it back to the client. This is a robust and reliable pattern for Node.js environments.
Step 2: Run npm run dev
.
Step 3: Test the login endpoint with curl
:
First, let's test the login endpoint to get a JWT. Open a new terminal and run the following command. The command sends a POST
request to the /login
endpoint with username: "admin"
in the request body.
curl -X POST http://localhost:3000/login -H "Content-Type: application/json" -d '{"username": "admin"}'
This will return a JSON object with a JWT. Copy this token for the next step.
Now, let's test the protected route. We'll use the token we just received in the Authorization
header. Replace <your_jwt_token>
with the token you copied.
curl http://localhost:3000/api/protected -H "Authorization: Bearer <your_jwt_token>"
You should get a success message with the decoded payload.
Finally, let's test the cached route. You’ll need to run a production build and run the file with node
for this to work.
First, run the following command. The 1000
millisecond delay in the code will make this request take about a second.
curl -o /dev/null -s -w 'Total: %{time_total}s\n' http://localhost:3000/api/public-data
Immediately run the exact same command again. This time, the response will be almost instantaneous because our custom cache middleware served the response directly from its in-memory store, completely bypassing the setTimeout
in the route handler. Run it a third time, and you'll see a similar near-instantaneous response.
Here's an example of what your terminal output should look like when testing the cache. The first request took around 1 second, but subsequent requests were a matter of milliseconds.
How to Create a Global Error Handler
You can define a single global error handler with app.onError()
. This is useful for handling unexpected errors in a centralized way, such as validation failures.
Add the following code to your src/index.ts
:
app.get('/users/:id', (c) => {
const id = c.req.param('id')
if (isNaN(Number(id))) {
throw new Error('User ID must be a number.')
}
return c.text(`User ID is ${id}`)
})
app.onError((err, c) => {
console.error(`${err}`)
return c.json({
success: false,
message: err.message,
}, 500)
})
Now, if you visit http://localhost:3000/users/abc
, you will get a JSON error response instead of an uncaught exception.
How to Handle Validation with Zod
For robust APIs, data validation is essential. Hono integrates seamlessly with Zod, a popular TypeScript-first schema validation library.
Step 1: Install the necessary dependencies:
npm install zod @hono/zod-validator
Step 2: Replace src/index.ts
with the validation example:
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
const app = new Hono()
// Define a Zod schema for the user creation data
const createUserSchema = z.object({
username: z.string().min(3).max(20),
email: z.string().email(),
age: z.number().int().positive(),
tags: z.array(z.string()).optional(),
})
app.post(
'/users',
zValidator('json', createUserSchema), // Use zValidator middleware
(c) => {
// The validated data is available on c.req.valid()
const user = c.req.valid('json')
console.log(`Creating user: ${user.username} with email ${user.email}`)
return c.json({
success: true,
message: 'User created successfully!',
user: user,
}, 201)
}
)
serve({ fetch: app.fetch, port: 3000 }, (info) => {
console.log(`Server is running on http://localhost:${info.port}`)
})
This is how the Zod validation is working:
We first define a schema called
createUserSchema
usingz.object()
. This schema is a blueprint for the expected data structure. We use Zod's built-in methods likez.string().min(3)
,z.string().email()
, andz.number().int().positive()
to specify validation rules for each property. For example,username
must be a string between 3 and 20 characters,email
must be a valid email format, andage
must be a positive integer.We then apply the
zValidator
middleware to our route handler. The first argument,'json'
, tells the middleware to validate the incoming request's JSON body. The second argument,createUserSchema
, tells it which schema to use for the validation.The
zValidator
middleware automatically does the heavy lifting. When a request hits the/users
endpoint, it will parse the JSON body and attempt to validate it againstcreateUserSchema
. If the data is invalid (for example, theemail
is not in a valid format), the middleware will immediately stop the request and return a400 Bad Request
status with a detailed error message, all without us having to write any manual checks.If the data is valid, the middleware makes it available on the
Context
object, which we can access withc.req.valid('json')
. Hono's type system ensures that this data is correctly typed according to the Zod schema, so we can use it safely in our handler.
Step 3: Run npm run dev
.
Step 4: Test with curl
(valid data):
curl -X POST http://localhost:3000/users -H "Content-Type: application/json" -d '{"username": "testuser", "email": "test@example.com", "age": 25}'
This will return a success message.
Test with invalid data (for example, bad email):
curl -X POST http://localhost:3000/users -H "Content-Type: application/json" -d '{"username": "testuser", "email": "invalid-email", "age": 25}'
This will automatically return a 400
status with a detailed error message from Zod.
How to Build a Full-Stack App with JSX
Hono supports server-side rendering with JSX, allowing you to build full-stack applications without needing a separate framework.
Step 1: Create src/components/Layout.tsx
:
import { html } from 'hono/html'
export const Layout = (props: { title: string; children?: any }) => html`
<!DOCTYPE html>
<html>
<head>
<title>${props.title}</title>
<style>
body { font-family: sans-serif; background: #f4f4f4; color: #333; }
.container { max-width: 800px; margin: 2rem auto; padding: 1rem; background: white; border-radius: 8px; }
header { border-bottom: 1px solid #ccc; padding-bottom: 1rem; }
footer { margin-top: 2rem; text-align: center; font-size: 0.8rem; color: #777; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>${props.title}</h1>
</header>
<main>
${props.children}
</main>
<footer>
<p>Powered by Hono</p>
</footer>
</div>
</body>
</html>
`
Step 2: Create src/components/PostItem.tsx
:
export const PostItem = (props: { post: { id: number; title: string; author: string } }) => (
<article style="border-bottom: 1px solid #eee; padding: 1rem 0;">
<h3><a href={`/posts/${props.post.id}`}>{props.post.title}</a></h3>
<p><em>By {props.post.author}</em></p>
</article>
)
Step 3: Update src/index.tsx
:
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { Layout } from './components/Layout'
import { PostItem } from './components/PostItem'
const app = new Hono()
// Mock data
const posts = [
{ id: 1, title: 'Getting Started with Hono', author: 'Alice' },
{ id: 2, title: 'Advanced Middleware Patterns', author: 'Bob' },
{ id: 3, title: 'Deploying Hono to the Edge', author: 'Charlie' },
]
app.get('/', (c) => {
return c.html(
<Layout title="My Hono Blog">
<h2>Recent Posts</h2>
{posts.length > 0
? posts.map(post => <PostItem post={post} />)
: <p>No posts yet!</p>
}
</Layout>
)
})
serve({ fetch: app.fetch, port: 3000 }, (info) => {
console.log(`Server is running on http://localhost:${info.port}`)
})
Make sure to update the dev
script in your package.json
file to have src/index.tsx
as the starting point.
"dev": "tsx watch src/index.tsx"
Step 4: Run npm run dev
and visit http://localhost:3000
. You will see a fully rendered blog page with the list of posts.
Deployment Guide for Hono
You have built your application, and now it's time to share it with the world. Here’s how you can deploy your Hono app to some of the most popular platforms.
How to Deploy to Node.js
For a traditional server environment, you can use the @hono/node-server
adapter and a process manager like pm2
for production.
src/index.ts
:
import { serve } from '@hono/node-server'
import app from './app' // Assuming your Hono app is in app.ts
serve({ fetch: app.fetch, port: 3000 })
You will then build your TypeScript to JavaScript and run pm2 start dist/index.js
to run it in the background.
How to Deploy to Cloudflare Workers
Hono's true power lies in its portability. The create hono
command can set up a project specifically for Cloudflare Workers.
Run the following command and select the cloudflare-workers
template:
npm create hono@latest my-app-hono-cloudflare-worker
create-hono version 0.19.2
✔ Using target directory … my-app-hono-cloudflare-worker
? Which template do you want to use?
aws-lambda
bun
❯ cloudflare-workers
cloudflare-workers+vite
deno
fastly
lambda-edge
The setup process is identical to the Node.js example, but the project structure is optimized for Cloudflare.
Once the project is set up, you only need to type one command to deploy your application to Cloudflare:
wrangler deploy
This command will prompt you to log in to your Cloudflare account and will handle the entire deployment process automatically.
Conclusion
You've made it! We’ve covered a lot in this guide. You started with a professional project setup and moved all the way through advanced routing, context management, complex middleware patterns, robust data validation, and full-stack JSX components.
You now have the knowledge and the tools to build serious, production-ready applications with Hono. Its simple API doesn't limit its power. Rather, it enhances it by getting out of your way and letting you focus on building great features. And with its helpful portability, you can be confident that the application you build today can be deployed to the platforms of tomorrow.
The web development ecosystem will continue to evolve, but by building on Web Standards, Hono is a framework that's built to last.
To continue your journey, I highly recommend exploring the official Hono documentation, which is full of even more examples and guides.