We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide12 min read

Hono, one codebase across every runtime

Hono is a Fetch based web framework running on Node, Bun, Deno, and at the edge. Routes, validation, a typed RPC client, and a comparison with Express.

Hono, one codebase across every runtime

A web framework written a dozen years ago assumed it ran on a server with Node. Today code also reaches the network edge, serverless functions, and environments without system modules. Hono is built around the standard request and response interface, so the same code runs anywhere that standard applies.

The framework weighs a dozen or so kilobytes minified, carries no dependencies, and ships a typed client letting the front end know the response shape with no code generation. Those three things decide its selection in practice.

Your first route

Code
Bash
pnpm add hono
Code
TypeScript
import { Hono } from 'hono'

const app = new Hono()

app.get('/tasks/:id', async (c) => {
  const id = c.req.param('id')
  const task = await getTask(id)

  if (!task) return c.json({ error: 'Not found' }, 404)
  return c.json(task)
})

export default app

The context object combines request and response in one place, so there are no two separate arguments to pass. Response methods are typed, and what comes back is a standard response rather than a framework specific structure.

Startup depends on the runtime and that is the only thing that differs. In an edge function you export the app, in Node you add a server adapter, in Bun and Deno the export alone suffices.

Validation and types

Validation attaches as middleware, and the result reaches the handler with types preserved.

Code
TypeScript
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

const schema = z.object({
  title: z.string().min(1).max(200),
  dueAt: z.string().datetime().optional()
})

app.post('/tasks', zValidator('json', schema), async (c) => {
  const data = c.req.valid('json')
  const task = await createTask(data)
  return c.json(task, 201)
})

The method reading validated data knows the type derived from the schema, so referencing a field outside it is a compile error. A malformed request is rejected before reaching the handler, with a response describing what did not match.

That arrangement removes the commonest class of bug in programming interfaces: unchecked input handled later by conditions scattered through the code.

The RPC client

This is what separates the framework from most alternatives. The application type can be exported, and a client on the front end reads paths, parameters, and response shapes from it.

Code
TypeScript
export type AppType = typeof routes
Code
TypeScript
import { hc } from 'hono/client'
import type { AppType } from '../server'

const client = hc<AppType>('https://api.example.com')

const response = await client.tasks[':id'].$get({ param: { id: '118' } })
const task = await response.json()

The front end then knows the response shape with no code generation and no separate schema file. Changing a field on the server side immediately raises a compile error wherever that field is used.

It requires a shared codebase or a shared types package. Where the front end and back end are separate repositories with no shared types, that capability falls away and you return to a classic interface description.

Middleware

Middleware is a function taking the context and a call to the next step, so code before that call runs before the handler and code after it runs afterwards.

Code
TypeScript
app.use('*', async (c, next) => {
  const start = performance.now()
  await next()
  c.header('X-Duration', `${Math.round(performance.now() - start)}ms`)
})

app.use('/api/*', jwt({ secret: process.env.JWT_SECRET! }))

The framework ships middleware for the commonest tasks: basic and token authentication, cross origin policy, request forgery protection, security headers, compression, body size limits, timeouts, and address restrictions.

Order matters and is the commonest source of mistakes. Authentication middleware attached after a public route will not protect it, and cross origin middleware attached after a handler adds no headers to a response the handler already returned.

Runtimes

This is the main argument for the framework, so it helps to understand exactly what it means.

Code built on the standard request interface runs unchanged wherever that standard exists: at the edge, in Bun, in Deno, in Node with an adapter, and in serverless functions at the major providers. Changing runtime means changing how it starts rather than rewriting routes.

The boundary sits at system modules. Code reaching for the file system or for processes runs in Node and not at the edge, since those simply do not exist there. The framework will not fix that, so when planning an edge deployment check the dependencies, not only your own code.

Bundle size matters practically here. Edge runtimes cap size and cold start time, so a dependency free framework leaves more room for application logic. On Cloudflare that is sometimes the difference between a deployment that fits and one that does not.

Grouping routes and project structure

An application with three routes fits in one file; one with thirty does not. The framework composes instances, so each area lives separately and mounts under a shared prefix.

Code
TypeScript
import { Hono } from 'hono'
import { tasks } from './routes/tasks'
import { users } from './routes/users'

const app = new Hono()
  .route('/tasks', tasks)
  .route('/users', users)

export type AppType = typeof app

The chained calls matter technically rather than aesthetically. The application type is derived from that chain, so assigning routes in separate statements loses the information the typed client needs. That is the commonest reason a client cannot see some routes.

Set error handling centrally rather than repeating it in every handler.

Code
TypeScript
app.onError((err, c) => {
  if (err instanceof ValidationError) return c.json({ error: err.message }, 400)
  console.error(err)
  return c.json({ error: 'Server error' }, 500)
})

app.notFound((c) => c.json({ error: 'Not found' }, 404))

Every error response then carries the same shape and clients need not handle several formats. It is a detail that saves considerable code on the other side once there are twenty routes.

Performance and cost in serverless

Choosing a light framework makes financial sense rather than only technical sense, and it helps to know why.

In serverless you pay for execution time, and that includes cold start. The smaller the bundle, the shorter the code loading time before the first request is served. On a rarely called function, cold start affects most invocations, so the difference registers in the bill.

The second matter is size limits. Edge runtimes cap deployment size, and every megabyte the framework occupies is one less for application dependencies. A project that does not fit needs splitting across several functions, which complicates deployment.

The third is request handling time itself. A router matching paths without scanning a linear route list is faster at scale, though across twenty paths that difference usually sits below the threshold of notice.

The practical conclusion: on a function serving a million requests a month, measure cold start before and after. On one serving a thousand, that optimisation does not matter and picking the framework your team knows is better.

Hono against the alternatives

FrameworkStrengthWeaknessPick it when
HonoLight weight, many runtimes, typed clientSmaller ecosystem than the veteranProgramming interfaces, edge, serverless
ExpressLargest ecosystem, universally knownHeavy, Node only, weak typingAn existing Node project
FastifyNode performance, mature pluginsNode onlyHigh traffic Node back end
Next.jsFront and back end togetherOverkill for an interface aloneApplication with a user interface

Choosing between the first two rows depends on where the code must run and whether types matter to you. On a Node project with a large plugin ecosystem the classic option is still fine. For a new programming interface that may reach the edge, light weight and portability win.

Consider the last row when the project has a front end anyway. Interface routes inside one application save a separate deployment, though once the back end grows into its own product the split happens sooner or later regardless.

When comparing, separate two things easily confused. The framework's own performance is rarely the bottleneck, since request handling time goes mainly on database queries and external calls. What does matter is where the code can run, because optimisation cannot make up for that. A framework tied to one runtime closes off edge deployment even when the rest of the architecture allows it.

The second thing is how well the team knows the tool. A framework nobody but one person knows is a risk that rarely pays off on a simple programming interface. Weigh that before deciding, since the technical differences here are smaller than the cost of learning under an urgent ticket.

Environment variables and resource access

How you reach configuration differs between runtimes, and it is one of the few things the framework does not hide entirely.

In Node you read variables from the process object, while at the edge they arrive in the request context, since a function has no global process. The framework exposes them through the context, so portable code reads them from there.

Code
TypeScript
type Env = {
  Bindings: {
    DATABASE_URL: string
    API_KEY: string
  }
}

const app = new Hono<Env>()

app.get('/data', async (c) => {
  const connection = connect(c.env.DATABASE_URL)
  return c.json(await connection.fetch())
})

Typing the environment carries practical value: a typo in a variable name becomes a compile error rather than an undefined value discovered in production.

The second matter is database connections. In Node a connection is created once and lives in a module, since the process persists. In serverless each function instance holds its own, so the database connection pool exhausts faster than traffic suggests. The answer is an HTTP based driver or pooling on the database provider's side, exactly as with Drizzle in the same arrangement.

The third is secrets. Keep keys in the provider's configuration rather than in a file deployed with the code, since that lands in an environment where several people can view it.

Common mistakes

The first is using system modules in code destined for the edge. It works locally in Node and stops working after deployment, and the error message rarely names the cause directly.

The second is wrong middleware order. Authentication attached after a route does not protect it, which is a hole rather than an inconvenience.

The third is no input validation. The framework will not check a request body for you, so without a schema data reaches the handler in any shape.

The fourth is returning errors without a fixed format. An error response sometimes as text and sometimes as an object forces clients to guess, so settle one shape and keep it in a shared layer.

The fifth is assuming the typed client works across separate repositories. Without a shared types package the front end has nowhere to get definitions and you return to an interface description.

The sixth is skipping a body size limit. An endpoint accepting a file without a cap is an easy way to exhaust memory or a serverless limit.

Streaming and events

An interface built on the standard request and response gives streaming with no extra libraries, which helps in applications with a language model.

Code
TypeScript
import { streamSSE } from 'hono/streaming'

app.get('/assistant', (c) => {
  return streamSSE(c, async (stream) => {
    for await (const chunk of modelResponse(c.req.query('question')!)) {
      await stream.writeSSE({ data: chunk })
    }
  })
})

A model response can take a dozen or more seconds, so without streaming the user watches a blank screen. Server sent events suffice in most cases and are simpler than websockets, since they run over an ordinary connection and reconnect on their own after a drop.

The framework also supports websockets where the runtime allows, though the differences deserve attention. Not every edge runtime offers them, and even where it does, a long lived connection sits badly with billing by execution time.

The practical rule: server sent events for one way data, websockets only once the client must send during the connection. The first case covers response streaming, notifications, and progress bars, meaning most applications.

FAQ

How does Hono differ from Express?

It is far lighter and built on the standard request and response interface, so beyond Node it runs at the edge, in Bun, and in Deno. It also carries typing from route to client, which the classic option does not offer without extra layers.

Is Hono production ready?

Yes, the framework is stable and ships middleware for authentication, security headers, limits, and cross origin policy. Note, though, that its plugin ecosystem is smaller than that of options on the market for over a decade.

Can Hono be used with Next.js?

Yes, you can serve programming interface routes with this framework inside a Next.js application. Separating them is often more sensible: the interface as its own deployment once the back end grows into a standalone product.

What does the RPC client give me?

The front end knows paths, parameters, and response shapes directly from the server application's type, with no code generation and no separate schema file. It requires shared types, so it works in one codebase or with a shared package.

Where will Hono not work?

In code requiring system modules run in an environment without them, at the edge for instance. The framework itself works there, while your code reaching for the file system or processes does not, so check dependencies before deploying.

Documentation sits on the project site, and the source code in the GitHub repository.