跳到正文
DEV Community· Hirohisa Tanaka·· 2 小時前AI 評分62

Hono v5 RC 實測:相較 v4 的變化與升級風險

I Tried Hono v5 RC: What Changed from v4, and What Breaks?

AI 導讀

作者以相同程式碼對照測試 Hono v4 與 v5.0.0-rc.1,指出除型別收緊外,還有三類型別檢查難以發現的遷移風險。包括 c.json(undefined) 由 200 空回應變為 500、onError() 由後註冊覆蓋改為處理器組合,以及萬用字元匹配規則改變。測試中核心路由效能差異在基準測試波動範圍內;安裝套件體積減少 46%,但核心 bundle 增加 302 B。

正文

I Tried Hono v5 RC: What Changed from v4, and What Breaks?

Will your app break when you upgrade to v5? Mine did.

TL;DR

With the Hono v5 release candidate out, I set up separate v4 and v5 environments and ran identical code in both. I measured five areas: type checking, runtime behavior, routing, performance, and size.

  • Every type-related breaking change was caught by the type checker. Start your migration with tsc --strict.
  • The real risks are three changes the type checker won't catch: c.json(undefined), the composition of onError() handlers, and changes to wildcard matching.
  • “Smaller” refers to the installed package. The final bundle actually grew by 302 bytes.

Here is the environment I used:

Item Value
Test date 2026-10-18
v4 hono@4.13.13
v5 hono@5.0.0-rc.1
Node.js v24.22.0
TypeScript 7.0.2
Machine Apple M4 (arm64)

Experimental code and raw data

Let me state my main takeaway up front:

The things that break in v5 are the places where v4 was permissive. c.req.json() returned any; c.req.query() concealed the possibility of undefined; and onError() silently overwrote previous handlers. Those were all places where v4's API gave a misleading picture. v5 is simply more honest. Migration may be inconvenient, but that is a different question from whether the design is sound.

This is a hands-on account of testing Hono v5 RC, written for Hono users who want to know what will break when they upgrade.

v5 is a release candidate. Behavior may change before the stable release. Everything here reflects testing on October 18, 2026.


1. Hono v5 Is “Hono for AI”

Before listing breaking changes, it's worth understanding what v5 is trying to accomplish. Once you see that, the individual changes start pointing in the same direction.

At Hono Conference 2026, creator Yusuke Wada described v5 as “Hono for AI.”

He described the goal of AI-friendly APIs as:

Abstracted, with fewer choices.

That was the key to understanding v5.

Changing c.req.json() so it no longer returns any, and turning onError() from a special function that can only be registered once into ordinary middleware, are part of the same idea: remove special cases and reduce the number of ways to do things.

A human might see any and reason, “Well, this will actually be a string, so it'll be fine.” When an LLM writes the code, that implicit assumption may not survive. If the types lie, the generated code can carry the same lie forward.

With that in mind, the changes below make a lot more sense.

2. Setting Up the Test Environments

I completely separated the directories to prevent v4 and v5 dependencies from mixing.

cd sandbox/v4
npm init -y && npm pkg set type=module
npm install hono@4
npm install -D typescript tsx @types/node

cd ../v5
npm init -y && npm pkg set type=module
npm install hono@next      # installed 5.0.0-rc.1
npm install -D typescript tsx @types/node

The RC is distributed under the next dist-tag. npm install hono@next is the method recommended by the official migration guide.

Here are the actual installed versions:

$ (cd sandbox/v4 && npm ls hono typescript)
v4@1.0.0
├── hono@4.13.13
└── typescript@7.0.2

$ (cd sandbox/v5 && npm ls hono typescript)
v5@1.0.0
├── hono@5.0.0-rc.1
└── typescript@7.0.2

$ node --version
v24.22.0

All tests used app.request() or app.fetch(). Since I didn't start an HTTP server, no runtime adapter was needed.

3. Stricter Types

c.req.json() Changes from any to unknown

In v4, this code passes type checking:

import { Hono } from 'hono'

const app = new Hono()

app.post('/users', async (c) => {
  const body = await c.req.json() // any

  return c.json({
    name: body.name.toUpperCase(), // TypeScript accepts this
  })
})

If a number is sent as name, the code crashes at runtime. The type system provides no protection.

Running the same code through tsc --strict with v5 gives:

src/typecheck-json.ts(9,11): error TS18046: 'body' is of type 'unknown'.

You now have to narrow the type before accessing its properties.

There are three approaches. The first one listed in the official guide is supplying a type argument:

const body = await c.req.json<{ name: string }>()

But this is not runtime validation. The type argument merely declares what you expect. You can still make a false declaration and the compiler will accept it. Personally, I think silencing the error this way misses the point of returning unknown: inspect the value before using it.

The straightforward alternative is a type guard. This passed v5's type checker:

app.post('/users', async (c) => {
  const body = await c.req.json()

  if (
    typeof body !== 'object' ||
    body === null ||
    !('name' in body) ||
    typeof body.name !== 'string'
  ) {
    return c.json({ error: 'Invalid body' }, 400)
  }

  return c.json({ name: body.name.toUpperCase() })
})

In real applications, the third option—using a validator and accessing the validated data through c.req.valid()—is usually the best choice. The official guide recommends it as well.

Destructuring c.req.query() Starts Producing Errors Everywhere

I think this change will affect more codebases:

const { name } = c.req.query()
const upper: string = name.toUpperCase()

const all = c.req.queries()
const tags: string[] = all.tag

This passes on v4. On v5:

src/typecheck-query.ts(7,25): error TS18048: 'name' is possibly 'undefined'.
src/typecheck-query.ts(10,9): error TS2322: Type 'string[] | undefined' is not assignable to type 'string[]'.
  Type 'undefined' is not assignable to type 'string[]'.

Calling query() without a key now has a return type of Record<string, string | undefined>. Consequently, destructured values start producing errors.

The Interesting Part: Runtime Values Are Unchanged

I sent GET /q?a=1&a=2 and compared the actual results.

v4 v5
c.req.query() {"a":"1"} {"a":"1"}
c.req.queries() {"a":["1","2"]} {"a":["1","2"]}

They're identical.

In other words, v5 didn't change the runtime behavior. In v4, looking up a nonexistent key could already return undefined; the types merely insisted it was a string.

Only the types became more honest. Every newly flagged line was already a potential bug in v4.

4. Error Handling Has Been Redesigned

This is the most interesting v5 change to me.

What Changed?

In v4, onError() looked like this:

app.onError((err, c) => {
  console.error(err.message)
  return c.json({ error: 'Internal Server Error' }, 500)
})

There was one handler for the whole app, with the signature (err, c).

In v5:

// Path-scoped error middleware
app.onError('/api/*', async (c, next) => {
  console.error(c.error?.message)
  c.header('X-Error-Handler', 'api')
  await next()
})

// Omitting the path registers '*' under the current basePath
app.onError((c) => {
  return c.json({ error: 'Internal Server Error' }, 500)
})

The signature becomes (c, next), and you read the error from c.error. You can also specify a path and register multiple handlers.

The intention is stated explicitly in PR #5541:

The goal is to model error and not-found handling using Hono's routing system, including middleware composition, path scoping, sub-application handling, and Router-level decoration.

Error handling has been rebuilt on top of the routing system. Internally, the handlers are registered with reserved methods called @ERROR and @NOT_FOUND. That means error handlers are resolved using the same underlying mechanism as ordinary routes.

notFound() now follows the same pattern:

app.notFound(
  '/api/*',
  async (c, next) => {
    c.header('x-not-found', 'api')
    await next()
  },
  (c) => c.text('API resource not found', 404)
)

Measurement: Does Path Scoping Work?

I tested it directly:

Request X-Error-Handler Status Body
/api/error api 500 {"error":"Internal Server Error"}
/other/error (absent) 500 {"error":"Internal Server Error"}

It works: only /api/* receives the extra header, while the common handler produces the final response.

Trying the same thing in v4 yields:

this.errorHandler is not a function

v4's onError() accepts only one argument. It saves the path string as if it were the handler, then fails when a request arrives. Registration itself doesn't complain. notFound() similarly threw TypeError: onNotFound is not a function.

Gotcha #1: Composition Instead of Overwriting

I expect this to be one of the biggest migration pitfalls.

I registered onError() twice and recorded which handlers ran:

Handlers executed Response
v4 ["second"] second
v5 ["first", "second"] second

v4 uses last-registration-wins replacement; v5 composes handlers.

Code that depended on a later registration replacing an earlier handler will now execute both.

When the first handler returns a response, the chain stops:

Handlers executed Response
v5 ["first"] first

Call next() to continue; return a response to stop. These are ordinary middleware rules.

And this won't produce a type error. Once you update the signature to (c, next), tsc passes. You may not notice the behavioral change until you actually run the app.

Gotcha #2: Old v4 Handlers Cause Confusing Runtime Errors

I also wondered what happens if you run a v4-style handler under v5.

First, the type checker correctly stops it:

src/typecheck-onerror-v4style.ts(7,21): error TS2339: Property 'message' does not exist on type 'Context<BlankEnv, string, any>'.
src/typecheck-onerror-v4style.ts(8,12): error TS2339: Property 'json' does not exist on type 'Next'.

It shows exactly what's changed: the first argument is now Context, and the second is Next.

But if you suppress those errors with @ts-expect-error and execute the code, you get:

Context is not finalized. Did you forget to return a Response object or `await next()`?

That message doesn't identify the root cause at all.

These were the arguments I observed:

{ "firstArgCtor": "Context", "firstArgIsContext": true, "secondArgType": "function" }

What your v4-style handler thinks is c is now the next function. It has no c.text(), so an exception occurs and the context never gets finalized. That's why the runtime reports this message.

Suppressing a type error can turn a straightforward migration fix into a long debugging session.

Throwing Values That Aren't Error Instances

In v4, throwing a string or object from a handler could escape past app.fetch().

In v5, the value is wrapped in an Error and sent to onError():

v4 v5
throw 'oops' Escapes outward message: 'oops' / cause: 'oops'
throw { code: 42 } Escapes outward message: '' / cause: { code: 42 }

One detail matters: when you throw an object, message is empty. The original value appears only in cause.

app.onError((c) => {
  console.log(c.error?.message) // '' if an object was thrown
  console.log(c.error?.cause)   // { code: 42 }
  return c.json({ error: 'wrapped' }, 500)
})

If your logs only include c.error.message, you'll be left with an unhelpful empty entry. Check cause too.

Being able to catch strange thrown values from third-party libraries is a welcome improvement.

5. The Most Dangerous Change the Type Checker Won't Catch

The behavior of c.json(undefined) has changed.

v4 v5
c.json(undefined) 200 / body length 0 500
c.json(() => 1) 200 / empty body 500

This is the actual exception thrown in v5:

TypeError: Value is not JSON serializable

The official explanation is that it now matches Response.json(). That makes sense.

The problem is that this does not produce a type error.

If a conditional expression can yield undefined and you pass it to c.json(), you can run tsc as many times as you like and it won't complain. Without a custom onError handler, the built-in handler returns 500, and you might discover the problem only in production.

Of all the migration checks in this article, I would prioritize this one the highest. You need tests to catch it.

One likely example is returning a database lookup directly:

const user = await db.query.users.findFirst({ where: eq(users.id, id) })
return c.json(user) // undefined if no user is found

In v4, an overlooked missing-user case could return 200 with an empty body rather than the intended 404. In v5, it becomes a 500. That means many of the new 500s were bugs already present in v4—v5 merely exposes them.

Another possible case is passing the return value of a void function directly. TypeScript might catch that when the type is void, but it can slip through if any is involved.

6. Wildcard Matching Is Now Consistent

In v4, Different Routers Gave Different Results

I registered the same routes in four routers under v4 and exhaustively checked the matches.

Route Path SmartRouter RegExpRouter TrieRouter PatternRouter
/x*/y /x/y ❌ ❌ ❌ ❌
/x*/y /xz/y ✅ ✅ ❌ ❌
/x*/y /x/z/y ❌ ❌ ❌ ❌
/x*y /xay ✅ ✅ ❌ ❌
/wild/*/card /wild//card ❌ ❌ ✅ ❌
/wild/*/card /wild/a/card ✅ ✅ ✅ ✅

The results disagreed in three of six cases.

For /wild/*/card, only TrieRouter matched /wild//card. In other words, switching routers could silently change which paths matched.

In v5, All Four Routers Agree

I repeated the same checks under v5:

Route Path SmartRouter RegExpRouter TrieRouter PatternRouter
/x*/y /x/y ✅ ✅ ✅ ✅
/x*/y /xz/y ✅ ✅ ✅ ✅
/x*/y /x/z/y ❌ ❌ ❌ ❌
/x*y /xay ❌ ❌ ❌ ❌
/wild/*/card /wild//card ❌ ❌ ❌ ❌
/wild/*/card /wild/a/card ✅ ✅ ✅ ✅

All four agree, matching the table in the official documentation.

The rules are now:

  • A * at the end of a segment is a wildcard; it can match an empty remainder.
  • A * in the middle of a segment, as in /x*y, is not a wildcard.
  • A segment consisting of just * matches one nonempty segment.

The changed results are /x*/y versus /x/y (❌ → ✅ in every router), and /x*y versus /xay (✅ → ❌ for the regex-based routers). These changes also escape static type checking.

Gotcha #3: Adding One Route Changes the Router

This was the most interesting discovery of the whole experiment.

If you register both /x*/y and /x/*, v5's RegExpRouter cannot handle the combination:

UnsupportedPathError: /x/*

SmartRouter catches that exception and falls back to TrieRouter.

I checked which router the app eventually selected:

Registered routes Router selected in v5
/x/* only SmartRouter + RegExpRouter
/x*/y + /x/* SmartRouter + TrieRouter

Adding a single route changed the router used at runtime.

And when I tested those conflicting routes under v4 and v5, even the selected handler differed:

v4 v5
Request to /x/y Handler for /x/* Handler for /x*/y

Routing can change silently, making this difficult to spot without targeted tests.

Measurement: How Much Does Fallback Cost?

A different router should affect performance, so I measured 50,000 requests to GET /users/:id across 12 rounds.

Router Median Mean Minimum
No conflict SmartRouter + RegExpRouter 138.68 ms 152.28 ms 132.35 ms
Conflict SmartRouter + TrieRouter 148.57 ms 163.99 ms 146.25 ms
Difference +7.1% +7.7% +10.5%

The fallback was slower on all three measures.

A disclosure: I reran this benchmark. With just three rounds, I initially measured a 3.3% difference. On rerunning it, I got 8.1%. Three rounds weren't enough for a stable conclusion.

Even after increasing the count to 12 rounds, the raw data contained outliers such as 287 ms and 271 ms (possibly from garbage collection or OS scheduling). I therefore consider the +10.5% difference in minimum times, which is less affected by those outliers, closer to the underlying cost.

Why Would It Be Slower?

According to the official documentation, RegExpRouter compiles registered routes into one large regular expression and obtains a match in a single operation. TrieRouter uses a trie and supports the complete range of patterns, but isn't as fast as RegExpRouter.

SmartRouter defaults to [new RegExpRouter(), new TrieRouter()]. It tries the faster router first, then selects the more general implementation if necessary, continuing to use its choice thereafter. The design is essentially: try the fast one; fall back to the fully featured one if needed.

This test exercised that fallback path, so a performance difference was expected.

With RegExpRouter's consolidated expression, I would expect matching costs to remain fairly stable as routes are added. TrieRouter traverses a tree, so I would expect costs to depend on path segments and the route structure. If that's correct, the 7–10% gap I measured in this small app might be larger in an app with hundreds of routes.

Conversely, in a small application the fallback probably won't be noticeable.

But the exact 7–10% isn't the most important part. The bigger issue is that adding one route can silently switch the router. You won't see that in the logs or the types.

And this might be about more than speed. As shown above, a change in router selection can affect which handler matches. My measurements established that across v4 and v5; I suspect combinations and registration order within v5 could expose related issues, but I didn't test that hypothesis fully.

7. Is It Faster?

I also benchmarked the core routing performance.

Caution: this is an in-process microbenchmark using app.fetch().
It does not represent real HTTP-server or production Cloudflare Workers performance.
It includes no network, TLS, or process-boundary costs.

I ran 100,000 requests to GET /users/:id, with 20,000 warmup requests, alternating v4 and v5 for 10 rounds.

Statistic v4 v5 Difference
Median 270.53 ms 266.50 ms v5 1.5% faster
Mean 278.24 ms 271.85 ms v5 2.3% faster
Minimum 258.31 ms 255.59 ms v5 1.1% faster

My conclusion: the difference is within benchmark noise.

v5 was slightly faster by each metric, but the distributions overlap substantially: v4's minimum (258.31 ms) is lower than v5's median (266.50 ms). This test doesn't justify claiming that v5 is faster.

Benchmarks Without Round Counts Are Hard to Interpret

Another disclosure: at five rounds I measured a 2.9% difference. After increasing the test to ten rounds, it fell to 1.5%.

Same code, same machine, same day. Changing only the number of rounds nearly halved the apparent advantage.

The router fallback benchmark behaved similarly (3.3% at three rounds; 7.1% at twelve).

When discussing single-digit percentage differences in microbenchmarks, publishing the round count and the distribution matters. I learned that firsthand. Every measurement in this article includes its round count.

And unchanged core routing performance isn't bad news. Performance isn't the primary goal of v5's redesign.

8. What Actually Got “Smaller”?

In v5, runtime adapters were moved out of the core hono package into separate @hono/* packages. I expect people to describe that as “making Hono lighter,” so I measured what actually became smaller.

Installed Package Size Really Did Shrink

I queried the npm registry's unpackedSize field:

Unpacked size File count
hono@4.13.13 1.23 MB 574
hono@5.0.0-rc.1 0.66 MB 310
Difference -46% -46%

Running du -sh on my local node_modules showed 2.8 MB → 1.5 MB, also -46%.

Yusuke Wada's Hono Conference 2026 presentation reported 1.29 MB → 0.70 MB, again -46%, with file counts of 574 → 310. The file counts matched exactly. The slightly different absolute sizes probably reflect differences in the referenced versions.

Three independent ways of looking at it produced the same reduction. I think that number is trustworthy.

But the Bundle Got Bigger

Here's the catch: I built actual bundles with esbuild.

Entry point v4 v5 Difference
Core only 18,528 B 18,830 B +302 B (+1.6%)
With adapter 22,760 B 22,923 B +163 B (+0.7%)

It increased rather than decreased.

Thinking about it, that makes sense. The package shrank because the CJS build was removed and adapters were split into separate packages. A CJS build that your bundler never used wasn't contributing to the bundle anyway. And once you install @hono/bun, the adapter code still comes along.

Code previously excluded by tree shaking has simply been separated at the package level. The amount of code ultimately bundled hasn't meaningfully changed.

When saying “Hono v5 is lighter,” specify whether you mean installation size or final bundle size. The first is true; the second was not supported by my measurements.

9. ESM-Only Packaging and Adapter Separation

It's ESM-Only, but require() Still Works

v5 removes the CJS build and becomes ESM-only. The official guide requires Node.js 22.12 or later.

Interestingly, under that requirement, require('hono') still works.

Test Result
require('hono') in v4 OK
require('hono') in v5 OK
import('hono/bun') in v4 Resolves
import('hono/bun') in v5 ERR_PACKAGE_PATH_NOT_EXPORTED

Node.js 22.12 and later support loading eligible ESM modules with require(), so using require() doesn't necessarily fail just because a package is ESM-only. The official guide mentions this, and my test confirmed it.

However, subpaths such as hono/bun have disappeared from exports, so those definitely break. The issue is not necessarily CommonJS-style loading; it's the old subpath imports.

All Eight Adapters Were Published on September 28, 2026

I checked their initial publication dates in the npm registry.

Package First published
@hono/bun / @hono/deno / @hono/cloudflare-workers 2026-09-28
@hono/vercel / @hono/netlify 2026-09-28
@hono/aws-lambda / @hono/lambda-edge 2026-09-28
@hono/service-worker 2026-09-28

All eight released version 1.0.0 on the same day. They're the packages split out for v5.

@hono/node-server is not one of these eight. Its first release was on January 7, 2023, and it was already a separate package during v4. Don't confuse it with the newly extracted adapters.

Adapter Separation and Supply-Chain Security

I had heard the argument that separating adapters could reduce the attack surface for supply-chain compromises. I decided to investigate.

On June 23, 2026, Yusuke Wada presented “Hono Supply Chain Compromise Countermeasures: Lessons from Three Libraries” at GMO Flatt Security's meetup on what OSS developers should do about software supply-chain attacks.

The three case studies were axios, TanStack, and Mastra, each with a different attack path:

Library Attack path
axios Social engineering: malware on a maintainer's machine stealing credentials
TanStack Cache poisoning through pull_request_target (“Pwn Request”); possible even without merging the PR
Mastra npm account takeover

Among the Hono-side mitigations was removing adapter functionality from the core. Other measures included:

  • Moving npm publishing from local machines to GitHub Actions with OIDC trusted publishing.
  • Avoiding pull_request_target.
  • Pinning GitHub Actions to lock down the environment.
  • Removing Dev Containers and Gitpod configuration.

This suggests adapter separation is not only about package size; it's also about reducing the attack surface. Size and file count were emphasized at Hono Conference 2026, but the same change had been discussed in a security context back in June.

But It Doesn't Reduce the Dependency Count

Here's something I verified myself.

I queried npm registry metadata for dependencies:

Runtime dependencies
hono@4.13.13 0
hono@5.0.0-rc.1 0
@hono/bun@1.0.1 0 (only hono as a peer)

For comparison, express had 28 and axios had 4 in the registry data I checked.

Hono already had zero external runtime dependencies in v4. Splitting adapters didn't reduce that count. It's zero in both versions.

What decreased was the amount of code shipped in the core package. As shown in section 8, the file count dropped from 574 to 310.

For a zero-dependency library, the package's own distributed code is itself part of the supply-chain attack surface. In that sense, removing adapters is better understood not as shrinking the dependency graph, but as roughly halving the code shipped together in a single package.

10. defineHandler(): The “Fewer Choices” Philosophy in Action

The experimental defineHandler() API is the clearest example of the “abstracted, with fewer choices” goal from section 1. I tried it because it shows where the design is heading.

The simplest example is:

import { defineHandler } from 'hono/factory'

app.get('/ping', defineHandler(() => ({ pong: true })))
ping -> 200 {"pong":true}

There's no c.json(). The returned value is converted into a Response. An object becomes JSON, a string or JSX becomes HTML, and null or undefined produces a 204.

Request and response validation can also be defined in one place:

app.post(
  '/users',
  defineHandler({ json: NameSchema })((c, input) => ({
    name: input.json.name.toUpperCase(),
  }))
)

NameSchema can be anything implementing Standard Schema, including Zod schemas.

With invalid input, I received:

schema ng -> 400 {"error":"Validation failed","issues":[{"slot":"json","issues":[{"message":"name must be a string","path":"name"}]}]}

I ran into one surprise: if you pass an ordinary validation function rather than a Standard Schema and throw inside it, you get a 500 instead of a 400.

invalid -> 500 Internal Server Error

Because the API also accepts validation functions, I assumed failures would be handled identically. But in my testing, only the Standard Schema path wrapped failures into an HTTPException that returned 400. If you need that 400 response, use the Standard Schema format.

And the type definition contains a comment that captures the philosophy perfectly:

A JSON Response by c.json() is not accepted, because it bypasses the response validation. Return the value instead.

Returning c.json() is explicitly forbidden by the types, because doing so would bypass response validation.

This is a shift from “several approaches usually work” to “there's one preferred approach and the types prevent alternatives.” It's a concrete expression of the design goal described earlier.

Note that defineHandler() is experimental and may change before the stable release.

11. Migration Checklist

Here are my priorities based on what I actually measured.

First, Run tsc --strict (Type-Detectable Changes)

  • Direct property access on the result of c.req.json().
  • Destructuring such as const { x } = c.req.query().
  • Treating c.req.queries() values as guaranteed string[].
  • Changing app.onError((err, c) => ...) to (c) => ... and reading c.error.

The compiler will point these out. You can fix them systematically.

Next, Fix the Three Changes Types Can't Detect

  • Any place that might pass undefined to c.json(): it now returns 500. Without tests, this is easy to miss.
  • Multiple registrations of onError() or notFound(): handlers compose rather than overwrite one another.
  • Routes containing wildcards: /x*/y now matches /x/y.

This is the real migration work. Passing the type checker is not enough.

Also look for conflicting routes such as /x*/y and /x/*, which can trigger router fallback.

Update Imports and Removed APIs

  • hono/<runtime> → @hono/<runtime> (eight packages).
  • hono/cloudflare-pages: no direct replacement; migrate to Workers.
  • app.mount() → mount() from hono/mount.
  • app.fire() → fire(app) from @hono/service-worker.
  • c.req.routePath / c.req.matchedRoutes → hono/route.
  • getColorEnabledAsync() → getColorEnabled(c.env).
  • toSSG() hook options → plugins (explicitly include defaultPlugin() when needed).

One small detail: UnOfficalStatusCode was corrected to UnofficialStatusCode. A long-standing typo from v4 has finally been fixed.

12. What I Didn't Verify

I couldn't investigate everything. Here are the remaining questions:

  • Priority of sub-app notFound() handlers when they're integrated via route(). The PR describes deeper-nested sub-apps taking priority, and registration order breaking ties at the same depth, but I didn't test it.
  • How basePath() interacts with onError() path resolution.
  • Benchmarks through a real HTTP server; all my measurements used app.fetch().
  • Performance in an actual Cloudflare Workers environment.
  • Integration of @hono/zod-validator with c.req.valid().
  • Hono CLI 1.0 RC.

And again, v5 was still an RC during testing. These behaviors could change before the stable release.

13. Conclusion

What struck me most was this: the places that break in v5 are the places v4 was too permissive.

The any from c.req.json() and the overly confident string types from c.req.query() gave developers misleading information. v5 stops doing that. The actual query behavior hasn't changed at all. Making onError() composable similarly replaces a special-case singleton with ordinary middleware.

The direction is consistent with “abstracted, with fewer choices.” Even defineHandler() rejecting c.json() through the type system follows the same philosophy.

The migration cost isn't negligible. Fortunately, tsc --strict highlights much of it. The three things to watch most closely are the changes static types don't detect: c.json(undefined), composition of onError() handlers, and wildcard matching. You'll need tests or manual inspection for those.

Performance was effectively unchanged. Installation size dropped by nearly half, but bundle size did not. The reason to upgrade to v5 isn't speed or bundle size; it's a cleaner, more consistent API design.

Would I deploy the RC to production today? No, I'd wait for stable. But running a build of an existing project against hono@next is absolutely worthwhile. The resulting type errors will immediately give you an idea of the migration effort.

Finally, two predictions for the stable release:

If one of these changes gets revised, I think it'll be defineHandler(). It's explicitly marked experimental, and behavior such as “throwing in a validation function returns 500 rather than 400” still has rough edges. Conversely, the onError() redesign and wildcard unification seem well specified in their PRs and implemented thoroughly, so I expect them to remain.

My guess is that the removal of hono/cloudflare-pages will cause the most migration pain. Most changes can be fixed by rewriting code, but this one has no direct replacement and requires a separate move to Workers. c.req.query() may produce far more type errors, but those are mechanical fixes. A single change without a drop-in replacement can cost more time than hundreds of compiler errors.

I'll revisit these predictions when the stable version arrives.

As for my project? It broke, just as expected.

References

來源:DEV Community · dev.to