Speeding up your React Router server-side-rendered (SSR) app with Hono
If you're running a React Router app in framework mode, you get SSR out of the box and for production it hands you @react-router/serve, a small server built on top of Express. While it does the job, Express carries a bit of history (and weight) with it, so if you're the kind of person who wants a leaner server, or just more control over what happens around your app, there's a really nice alternative that drops in with almost no glue.
Hono (and its Node server)
The createRequestHandler from react-router gives you a function that takes a standard web Request and resolves to a standard web Response, that's the whole contract. It doesn't care about Node, Express, or any particular server, it just wants a Request and it'll give you back a Response.
Hono is built on those exact same web standards and, inside its handler, context.req.raw is already a web Request. This means that passing a request from Hono to React Router is basically one line, with no conversions, no bridging and no fuss.
Since Hono itself doesn't listen on a port, it's just a handler, to actually run it on Node you'll have to bring in @hono/node-server, whose serve function takes your app.fetch and wires it up to Node's built-in http server.
Install both (npm i hono @hono/node-server) before this:
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { createRequestHandler, type ServerBuild } from "react-router";
const build = (await import("./build/server/index.js")) as ServerBuild; // This one here is the output of your bundle
const handler = createRequestHandler(build);
const app = new Hono();
app.use(async (context) => handler(context.req.raw));
serve({ fetch: app.fetch, port: 3000 });
That's it. serve spins up the Node HTTP server, every request gets turned into a web Request, Hono passes it to React Router, and the Response goes back out.
Squeezing the last bit of performance
If you must make every request count and you're not going to use Hono's ecosystem (middlewares and everything else), you can improve your performance by dropping the app entirely and relying only on a bare request listener.
import { createServer, type RequestListener } from "node:http";
import { getRequestListener } from "@hono/node-server";
import { createRequestHandler, type ServerBuild } from "react-router";
const build = (await import("./build/server/index.js")) as ServerBuild;
const handler = createRequestHandler(build);
const server = createServer(getRequestListener((request) => handler(request) as RequestListener));
server.listen({ port: 3000 });
Why not use Fastify?
Fastify is fast, mature, and plenty of people already reach for it by default. It's a great framework overall. Why not then?
The difference comes down to what each one speaks natively. Fastify is built on Node's own HTTP primitives, IncomingMessage and ServerResponse (you can reach them as request.raw and reply.raw). It doesn't hand you a web Request, and it doesn't want a web Response back. React Router deals in nothing but web Request and Response, so to marry the two, you would end up having to write an adapter to make it work.
Since you already have Node's own server available, you don't need to put Fastify in its place, because it would generate unnecessary overhead. Where Fastify really shines is when the server itself is the product: JSON APIs with schema-based validation and its blazing fast JSON stringify serialization. With that said, for an SSR app where your whole "API" is basically "give this web request to React Router and return the response", Hono removes an entire category of mess you'd have to maintain.
Before wrapping everything up, there are two things worth taking into consideration:
- React Router's loadContext is not being provided to the handler function in the examples, so you'll need to write your own logic to provide it. I suggest taking a look at how @react-router/express, or some other library, does it;
- There are a lot of things going on in your server when you call react-router-serve, that's why I also recommend taking a look at how it goes, so you don't miss anything you didn't know you needed.
Wrap-up
Swapping to Hono doesn't ask much of you. React Router already talks in web Request and Response, Hono does too, and @hono/node-server handles the Node part for you. You start with a handful of lines and from there you may add (or not) port handling, static serving, logging requests, etc. It's the performance boost your SSR app could be looking for.
---
<small><small><small>This article was created with the help of AI because, while I'm good at being a software engineer (I hope so), I suck at being a writer. On the other hand, the silly jokes and wordplay are completely mine because I'm really good at those. Also, the content comes from things I've done or learned, has been verified and is edited to be straight to the point, since AI loves to stall, but I hate it.</small></small></small>
Agent DX 〜 AI時代にフレームワークHonoは必要か
AIがユーザーになりました。我々は人間向けの開発者ではなく、AI向けの開発者体験を考えなくてはいけません。
我々は人間です。雰囲気で「フレームワークは必要ない」「フレームワークは必要」と言っても、AIの意見ではありません。人間の意見です。説得力がありません。我々人間はAIのことをもっと知らなくてはいけません。
そこで、Honoでは「Agent DX」というプロジェクトを立ち上げて、AI向けの開発者体験をちゃんと測りました。以下3つのカンファレンスでAgent DXについて話しました。
AI時代のWebフレームワークはどこへ行く? - フロントエンドカンファレンス福岡 2026
Hono in this era - Hono Conference 2026
Hono: Web application primitives for the AI era - Cloudflare Connect 2026
今回はそれらを記事化してみます。
Agent DXとは
AIエージェントがHonoを使うときの開発者体験 = DXを測って改善するプロジェクトです。リポジトリは以下です。
honojs/agent-dx
また、結果を以下のページで公開しています。
agent-dx.hono.dev
AI Evalsで測る
測り方には「AI Evals」という手法を使います。AI Evalsは固有名詞ではなく、一般的な名称でNext.jsやAngularといったフレームワークも取り入れています。
Next.js:Next.js Agent Evals
実際の Next.js のタスクをエージェントに解かせて採点している。AGENTS.md の効果も公開している
Angular:web-codegen-scorer
生成したコードのビルド成功、実行時エラー、アクセシビリティなどを評価する
Vercel:@vercel/agent-eval
評価の基盤。ドキュメントや MCP の有無、モデルの違いを比べられる
どのようにやるかというと、エージェントにタスクを解かせて採点します。
エージェントにプロンプトを与える
エージェントがサンドボックスでコードを書く
書かれたコードをテスト、もしくは静的解析する(採点する)
成功率・トークン・時間を記録して比べる
ポイントは採点です。中身は普通のテストと型チェック、静的解析で、LLMには判定させません。テストはエージェントからは見えないところに置いておきます。
ちなみに、AI Evalsの環境として、Astroチームが作っているAIエージェント向けフレームワークのFlueを使っています。
実験は3つやりました。
Adoption → AIはHonoを使うのか?
Value → Honoは必要か?
Practical → AI向けにHonoを使いやすくするには?
一つずつ見ていきます。
実験 1. Adoption 〜 AIはHonoを使うのか?
AIがコードを書く時、フレームワーク、とりわけHonoを使うのか?というのをAI Evalsで測ってみました。
まっさらなディレクトリでエージェントにアプリを作らせます。どのフレームワークを使うか指示は出しません。そして、書いたコードを静的解析して、どのフレームワークを使ったかを見ます。
プロンプトは以下のようなものです。
Create a Cloudflare Workers application in TypeScript.
It should respond to GET /health with the JSON {"ok":true}.
ランタイムを複数用意しました。Cloudflare Workers、Bun、Node.js、Denoの4つです。シナリオは、GET /health だけの最小アプリから、バリデーション付きのTODO APIまで用意しました。モデルはClaude Haiku 4.5とClaude Opus 5を使います。
まずはHaiku 4.5の結果です。
ヒントなしだと、フレームワークを使いません。素のハンドラを書きます。
次に 「フレームワークを使って」と明示的に言うと、ランタイムごとの定番を選びます。Cloudflare WorkersはHono、BunはElysia、Node.jsはExpress、DenoはOakとなります。
次にOpus 5の結果です。
ヒントなしだと、やはりフレームワークを使いません。「フレームワークを使って」と言うと、Node.jsでもHonoを選びます。
分かったことは以下となります。
ヒントなしだと、AIは素のハンドラを書く
賢いモデルほどHonoを選ぶ
面白いですね!
package.json に hono があると
さらに別の計測もしました。package.json を置いて、dependencies に hono を入れておきます。
package.json
{
"dependencies": {
"hono": "^4"
}
}
すると、AIはNode.jsでもHonoを使いました!
Cloudflare Workers → 5/5 Hono
Node.js → 5/5 Hono
つまり、AIは自分からはフレームワークを使わないですが、ヒントがあればHonoを選ぶことが分かります。
実験 2. Value 〜 Honoは必要か?
AIにとってフレームワークは必要か?とりわけHonoは必要なのか?をAI Evalsで測ってみました。
同じタスクを二通りでやらせます。
素の fetch ハンドラで書く
Honoで書く
タスクは2つ用意しました。
shop API → 商品・カート・チェックアウト。状態を持つ複雑なAPI
notes API → ログイン・Bearer認証・CORS・JSONエラー。認証まわり
結果はこちらです。Haiku 4.5で各10回です。
成功率が上がる → shop API で2/10 → 4/10
コードが30〜40%減る
トークンが15〜30%減る
AIが書いても、フレームワークの恩恵はあることが分かります。正確になる、書く量が減る、少ないトークンで書ける。つまり、AIにもライブラリは要るのです!
以上、Adoption、Valueの結果は、測るまでは分からなかった・確信を持てなかったことなので、測ることは大事です!
実験 3. Practical 〜 AI向けにHonoを使いやすくするには?
ここまでで、きっかけがあればAIはHonoを使うこと、そしてHonoを使う効果があることが分かりました。では、AIにとって、さらにHonoを使いやすくするにはどうすればいいか?最後の実験です。
エージェントにタスクを与えて、結果を見ます。そしてHonoの側を変えて、また試します。これをくり返しました。
中でも印象的だったのが、「開発サーバー」についてです。
エージェントが開発サーバーを立ち上げる
Honoには、プロジェクトのテンプレートを作る create-hono があります。そのCloudflare Workersのテンプレートをそのまま使い、エンドポイントを追加してもらいました。AGENTS.mdは入れていません。
結果です。Haiku 4.5、Sonnet 5、Opus 5で各5回です。
15/15 が wrangler dev を起動
サーバーを立ち上げたまま、bashごとハングするケースもあった
例えばSonnet 5は成功率20%。5本中4本が600秒ハング
原因は package.json にある dev スクリプトでした。つまり、エージェントは、開発の最中、このdevコマンドを実行し自らハングしてしまっていたのです。
package.json
{
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy --minify"
}
}
Hono CLI
これを解決しようと開発してるのがHono CLIです。
honojs/cli
Hono CLIは、Honoのアプリを作るのを助けるAIエージェント向けのCLIです。いくつかコマンドがあります。 hono request ではサーバーを立ち上げずに、JavaScriptのRequest/Responseレイヤーで、Honoのアプリにリクエストを送ることができます。
オプションはcurlライクなものです。
GET /
hono request /
POST /users
hono request -X POST -d '{"name":"Momo"}' /users
また、複数のリクエストを一度に送って、pass / failを判定する hono batch もあります。
CLIとSkillsを入れると
同じタスクで、Hono CLIとSkillsを使わせました。
15/15、100%開発サーバーを立てない
初手で hono batch
全モデルで成功率100%、約30秒
Sonnet 5の結果です。
Opusに「カウンターをつくって」→「パスワードつけて」と頼んだ例もあります。1回ずつです。
CLIなし → 13ターン・70秒。他の開発サーバーまで止めた
CLIあり → 8ターン・39秒。サーバーは立てない
どちらも正しく動く
つまり、Hono CLIを使うと、次のようになります。
サーバーを立てない → ハングしない、他のサーバーを止めない
サーバーを待たない → 速いこともある
Hono CLIがツール(hono requestなど)を提供しているので、エージェントが一から作成する必要がない
Hono CLIのおかげで、Honoを良くすることができます!
ちなみに、Hono CLI(現在1.0のRC版が出てます)とSkillsは以下で試せます。
npm i -D @hono/cli@next
npx skills add honojs/skills
詳しくは以下のページを参考にしてください。
hono.dev
人間向けのDXとAI向けのDX
AIが開発サーバーを立てたのには、きっかけがありました。package.jsonの scriptsにあるdevコマンドです。
よく考えると、dev コマンドは人間にとってのDXなのだと思います。打ちやすくて、みんなが知っているショートカット。そして、人間は開発サーバーを立ち上げブラウザでlocalhost:8787等にアクセスし結果を確認します。
一方で、AIに開発サーバーは必要ないかもしれません。リクエストを投げて、レスポンスを確かめられれば十分です。
今回の開発サーバーの件を振り返ると、人間にとってのDXを向上させるものが、AIにとっては裏目に出たと言えます。
つまり、人間向けのDXとAI向けのDXがあるのです!
まとめ
Agent DXで分かったことをまとめます。
Adoption → きっかけがあればHonoを使う。package.json にあれば100%
Value → AIが書いても、Honoの恩恵はある
Practical → package.jsonにあるdevスクリプトでエージェントがハングする。Hono CLIとSkillsで防げる
AI時代にフレームワークHonoは必要か?その答えは⋯ AI時代でもHonoはあった方がいい!そして、AI Evalsを使ってAI向けのDXは改善していくことができるのです!
PRを無効化する代わりに、「Issue立ててください、forkしたブランチがあるならそのアドレスを載せてください、それを取り込むこともあるよ」って書いたら、forkブランチのアドレスばかりになったので、その記載も消した。
hono#5593
62 likes · 8 reposts · 5,947 views
I Tried Hono v5 RC: What Changed from v4, and What Breaks?
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
- Migrating to v5 — Hono — Official breaking-change guide; the source for my test cases.
- feat!: add routable error and not-found middleware (honojs/hono#5541) — onError() / notFound() redesign, reserved route methods, and sub-app priority rules.
- Hono Conference 2026 — Yusuke Wada — The “Hono for AI” framing and package size figures.
- honojs/hono releases — Release dates of 5.0.0-rc.0 (2026-10-08) and 5.0.0-rc.1 (2026-10-10).
- Hono Supply Chain Compromise Countermeasures — Yusuke Wada — Case studies of axios, TanStack, and Mastra; adapter extraction as a mitigation. (Japanese)
- “What Should OSS Developers Do Now?” meetup report — kouki_engineer — Attendee report on the June 23, 2026 GMO Flatt Security meetup. (Japanese)
- npm registry: hono — Source for dependencies, unpackedSize, and fileCount.
- Standard Schema — Common schema interface accepted by defineHandler().
- Routers — Hono — RegExpRouter, TrieRouter, SmartRouter, and default selection.
- Mount Middleware — Hono — Migration destination for the removed app.mount().
5 reactions · 3 comments
Hono v5 RC を触ってみた。v4 から何が変わり、何が壊れるのか
> あなたのアプリ、v5 にしたら壊れますか。僕のは壊れました。
この記事の結論
Hono v5 の RC が出たので、v4 と v5 の環境を別々に作って、同じコードを両方で動かして差分を取りました。型チェック・ランタイム挙動・ルーティング・性能・サイズの 5 系統を実測しています。
- 型で捕まる変更は、全部ちゃんと型で捕まった。 移行の第一歩は tsc --strict を通すことです
- 本当に怖いのは、型でも捕まらない 3 つ。 c.json(undefined)、onError() の合成化、ワイルドカードのマッチ変更です
- 「軽くなった」はインストールサイズの話。 最終バンドルはむしろ 302 バイト増えました
実測した環境です。
| 項目 | 値 |
| --- | --- |
| 検証日 | 2026-10-18 |
| v4 | hono@4.13.13 |
| v5 | hono@5.0.0-rc.1 |
| Node.js | v24.22.0 |
| TypeScript | 7.0.2 |
| マシン | Apple M4 (arm64) |
実験コードと生データはこちらです: github.com/tanahiro2010/slides/tree/main/slides/s…
そして、この記事で一番伝えたいことを先に書いておきます。
v5 で壊れるのは、v4 が緩かったところです。 c.req.json() が any を返していたのも、c.req.query() が undefined を隠していたのも、onError() が黙って上書きされていたのも、全部 v4 の側が嘘をついていた箇所でした。v5 はそれを正直にしただけです。だから移行は面倒ですが、面倒の量と設計の筋の良さは別の話です。
> この記事は Hono v5 の RC を触ってみた記録です。対象は、Hono を実際に使っていて「v5 にしたら何が壊れるのか」を知りたい人。
> v5 は RC 版です。正式リリース時に仕様が変わる可能性があります。 この記事の内容は 2026-10-18 時点のものです。
---
1. v5 は「AI のための Hono」だった
破壊的変更の一覧から入る前に、v5 が何を目指しているのかを先に書きます。ここを押さえると、個々の変更が全部同じ方向を向いていることが分かります。
作者の Yusuke Wada さんは、Hono Conference 2026 の発表で v5 を 「AI のための Hono」 と表現しています。
そして AI に優しい API の設計目標として、こう挙げています。
> 抽象化されていて、選択肢が少ない
これが v5 を読み解く鍵でした。
c.req.json() が any を返さなくなったのも、onError() が「1 つだけ登録できる特別な関数」から「普通のミドルウェア」になったのも、やることは同じです。特殊な抜け道を潰して、選択肢を減らしている。
人間なら「ここは any だけど実際は文字列が来るから大丈夫」と空気を読めます。LLM にコードを書かせると、その空気は読まれません。型が嘘をついていれば、嘘のまま出力されます。
そう考えると、この記事で後から出てくる変更が全部つながります。
2. 検証環境を作る
v4 と v5 の依存が混ざらないよう、ディレクトリを完全に分けました。
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 # 5.0.0-rc.1 が入る
npm install -D typescript tsx @types/node
RC は next タグで配布されています。npm install hono@next が公式ガイドの案内する方法です。
実際に入ったバージョンがこれです。
$ (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
検証はすべて app.request() と app.fetch() で行っています。HTTP サーバーを立てていないので、ランタイムアダプターは不要です。
3. 型が厳しくなった
c.req.json() が any から unknown へ
v4 ではこのコードが型チェックを通ります。
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(), // ← 通ってしまう
})
})
name に数値が送られてくれば実行時に落ちます。型は何も守ってくれていません。
v5 で同じコードを tsc --strict に通すと、こうなります。
src/typecheck-json.ts(9,11): error TS18046: 'body' is of type 'unknown'.
絞り込むまでプロパティに触れなくなりました。
直し方は 3 つあります。公式ガイドが最初に挙げているのは型引数を渡す形です。
const body = await c.req.json<{ name: string }>()
ただしこれは実行時検証ではありません。 型引数は「こう来るはずだ」という宣言でしかなく、嘘をついても通ります。unknown 化の意図は「中身を確かめてから触れ」ということなので、これで黙らせるのは本末転倒だと個人的には思っています。
素直なのは型ガードを書く形です。これは v5 でも型エラーになりませんでした。
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() })
})
実務的には 3 つ目、バリデーターを使って c.req.valid() で受けるのが正解です。公式ガイドもそう案内しています。
c.req.query() の分割代入が一斉に赤くなる
こちらの方が影響範囲は広いと思います。
const { name } = c.req.query()
const upper: string = name.toUpperCase()
const all = c.req.queries()
const tags: string[] = all.tag
v4 は通ります。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[]'.
キー無しで呼んだときの戻り値が Record<string, string | undefined> になったので、分割代入している箇所は全部赤くなります。
考察:実行時の値は何も変わっていない
ここが面白いところでした。GET /q?a=1&a=2 を投げて、実際に返ってくる値を比べてみます。
| | v4 | v5 |
| --- | --- | --- |
| c.req.query() | {"a":"1"} | {"a":"1"} |
| c.req.queries() | {"a":["1","2"]} | {"a":["1","2"]} |
完全に同じです。
つまり v5 は挙動を変えていません。v4 でも存在しないキーを引けば undefined が返っていたのに、型が string だと言い張っていただけです。
変わったのは型が正直になったことだけ。赤くなった箇所は、もともと v4 の時点でバグり得た箇所です。
4. エラーハンドリングが再設計された
ここが v5 で一番面白い変更です。
何が変わったのか
v4 の onError() はこうでした。
app.onError((err, c) => {
console.error(err.message)
return c.json({ error: 'Internal Server Error' }, 500)
})
アプリ全体に 1 つだけ。シグネチャは (err, c)。
v5 はこうなります。
// パススコープ付きのエラーミドルウェア
app.onError('/api/*', async (c, next) => {
console.error(c.error?.message)
c.header('X-Error-Handler', 'api')
await next()
})
// パス省略 = 現在の basePath 内の '*' に登録
app.onError((c) => {
return c.json({ error: 'Internal Server Error' }, 500)
})
シグネチャが (c, next) になり、エラーは c.error から読みます。そしてパスを指定でき、複数登録できるようになりました。
この変更を入れた 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 と @NOT_FOUND という予約メソッドで Router に登録されています。つまり内部的には、エラーハンドラも普通のルートと同じ仕組みで引かれています。
notFound() も同じ形になりました。
app.notFound(
'/api/*',
async (c, next) => {
c.header('x-not-found', 'api')
await next()
},
(c) => c.text('API resource not found', 404)
)
計測:パススコープは本当に効くのか
実際に動かして確かめました。
| リクエスト | X-Error-Handler | status | body |
| --- | --- | --- | --- |
| /api/error | api | 500 | {"error":"Internal Server Error"} |
| /other/error | (なし) | 500 | {"error":"Internal Server Error"} |
効いています。/api/* のときだけヘッダが付き、最終的なレスポンスは共通のハンドラが返しています。
同じことを v4 でやろうとすると、こうなります。
this.errorHandler is not a function
v4 の onError() は引数を 1 つしか取らないので、パス文字列をハンドラとして保存してしまい、リクエストが来た時点で落ちます。登録時には何も言われません。notFound() も同様に TypeError: onNotFound is not a function になりました。
ハマりポイント①:上書きではなく、合成になった
ここが移行で一番事故ると思います。
onError() を 2 回登録して、どちらが走るか記録しました。
| | 実行されたハンドラ | レスポンス |
| --- | --- | --- |
| v4 | ["second"] | second |
| v5 | ["first", "second"] | second |
v4 は後勝ちの上書き、v5 は合成です。
v4 では「あとから登録したものが前のを潰す」という前提のコードが書けました。それを v5 に持っていくと、両方走ります。
1 つ目がレスポンスを返した場合はチェーンが止まります。
| | 実行されたハンドラ | レスポンス |
| --- | --- | --- |
| v5 | ["first"] | first |
next() を呼べば次に渡り、レスポンスを返せばそこで終わる。普通のミドルウェアと同じ規則です。
そしてこれは型エラーになりません。 シグネチャさえ (c, next) に直せば tsc は通ります。実際に動かすまで気づけない類の変更です。
ハマりポイント②:v4 の書き方のまま動かすと、意味不明なエラーが出る
v4 スタイルのハンドラを v5 で動かすとどうなるか、気になったので試しました。
まず型チェックはちゃんと止めてくれます。
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'.
第 1 引数が Context、第 2 引数が Next になったことがそのまま出ています。ここで気づけます。
問題は、これを @ts-expect-error で黙らせて実行したときです。
Context is not finalized. Did you forget to return a Response object or `await next()`?
原因を全く指していません。
実際に観測された引数はこうでした。
{ "firstArgCtor": "Context", "firstArgIsContext": true, "secondArgType": "function" }
v4 のつもりで c として触っているものが next 関数なので、c.text() が存在せず例外になり、結果として Context が確定しないまま終わる。だからこのメッセージが出ます。
型エラーを握りつぶすと、デバッグがかなり遠回りになるパターンです。
非 Error を throw したときの扱い
v4 では、ハンドラから文字列やオブジェクトを throw すると app.fetch() の外まで漏れていました。
v5 では Error にラップされて onError に流れます。
| | v4 | v5 |
| --- | --- | --- |
| throw 'oops' | 外に漏れる | message: 'oops' / cause: 'oops' |
| throw { code: 42 } | 外に漏れる | message: '' / cause: { code: 42 } |
注意したいのはオブジェクトを投げた場合です。message は空文字になります。 元の値は cause にしか入りません。
app.onError((c) => {
console.log(c.error?.message) // オブジェクトを投げると '' になる
console.log(c.error?.cause) // { code: 42 }
return c.json({ error: 'wrapped' }, 500)
})
ログに c.error.message だけ出していると、何も分からないログが残ります。cause を見る必要があります。
サードパーティのライブラリが変なものを投げてきても拾えるようになったのは、素直にありがたい変更です。
5. 型では捕まらない、一番危ない変更
c.json(undefined) の挙動が変わりました。
| | v4 | v5 |
| --- | --- | --- |
| c.json(undefined) | 200 / ボディ長 0 | 500 |
| c.json(() => 1) | 200 / ボディ空 | 500 |
v5 で実際に投げられている例外がこれです。
TypeError: Value is not JSON serializable
Response.json() と同じ挙動に揃えた、というのが公式の説明です。筋は通っています。
問題は、これが型エラーにならないことです。
c.json() に渡す値が条件分岐の結果 undefined になり得るコードは、tsc を何回通しても何も言われません。onError を登録していなければ組み込みハンドラが 500 を返すので、本番で初めて気づくことになります。
この記事で挙げる移行チェックの中で、優先度は一番高いと思っています。テストがないと検出できません。
具体的にどこで踏むかを予想しておきます。一番ありそうなのは、DB から引いた 1 件を c.json() にそのまま渡しているコードです。
const user = await db.query.users.findFirst({ where: eq(users.id, id) })
return c.json(user) // 見つからなければ undefined
v4 では 404 を返すつもりが 200 で空ボディ、という見落としがそのまま通っていました。v5 はこれが 500 になります。つまり v5 で 500 が出る箇所は、v4 の時点ですでにバグっていた箇所だと思います。壊れたのではなく、隠れていたバグが表に出るだけ、という見方をしています。
もうひとつありそうなのが、void を返す関数の戻り値をそのまま渡しているケースです。こちらは型が void なので tsc が止めてくれる可能性もありますが、any を経由していると抜けます。
6. ワイルドカードのマッチングが統一された
v4 は Router ごとにバラバラだった
v4 で同じルートを 4 つの Router に登録して、マッチするかどうかを総当たりしました。
| ルート | パス | 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 | ✅ | ✅ | ✅ | ✅ |
6 ケース中 3 ケースで結果が割れています。
/wild/*/card に至っては、TrieRouter だけが /wild//card にマッチします。Router を切り替えた瞬間にルーティングが変わるということです。
v5 は完全に一致した
同じ表を v5 で取り直しました。
| ルート | パス | 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 | ✅ | ✅ | ✅ | ✅ |
4 つとも一致しました。 公式ドキュメントが示している表とも完全に同じです。
規則はこうなりました。
- セグメント末尾の * はワイルドカード。何にもマッチしなくてよい
- セグメント途中の *(/x*y など)はワイルドカードではない
- セグメントが * だけなら、空でない 1 セグメントにマッチ
v4 から挙動が変わったのは /x*/y と /x/y(全 Router で ❌ → ✅)、それと /x*y と /xay(RegExp 系で ✅ → ❌)です。ここも型では捕まりません。
ハマりポイント③:ルートを 1 行足すと、Router が入れ替わる
ここが今回の検証で一番面白かったところです。
/x*/y と /x/* を同時に登録すると、v5 の RegExpRouter はこれを処理できません。
UnsupportedPathError: /x/*
そして SmartRouter は、RegExpRouter が投げたこの例外を受けて TrieRouter にフォールバックします。
実際に、アプリが最終的にどの Router を選んだかを見てみました。
| 登録したルート | v5 で選ばれた Router |
| --- | --- |
| /x/* のみ | SmartRouter + RegExpRouter |
| /x*/y + /x/* | SmartRouter + TrieRouter |
ルートを 1 行足しただけで、実行時に選ばれる Router が変わりました。
しかも同じ競合ルートを v4 と v5 で引き比べると、マッチするハンドラ自体が違います。
| | v4 | v5 |
| --- | --- | --- |
| /x/y へのリクエスト | /x/* の方 | /x*/y の方 |
ルーティングの結果が静かに変わるので、型でもテストでも気づきにくい変更です。
計測:フォールバックのコスト
Router が変わるなら性能も変わるはずなので、測りました。GET /users/:id を 50,000 回、12 ラウンドです。
| | Router | 中央値 | 平均 | 最小 |
| --- | --- | ---: | ---: | ---: |
| 競合なし | SmartRouter + RegExpRouter | 138.68 ms | 152.28 ms | 132.35 ms |
| 競合あり | SmartRouter + TrieRouter | 148.57 ms | 163.99 ms | 146.25 ms |
| 差 | | +7.1% | +7.7% | +10.5% |
中央値・平均・最小のすべてで、フォールバック側が遅くなりました。
ひとつ正直に書いておくと、これは測り直した値です。 最初に 3 ラウンドで測ったときは差が 3.3% しか出ず、再実行したら 8.1% になりました。ラウンド数が足りず、結論が安定していませんでした。
12 ラウンドに増やしたあとの生データには 287 ms や 271 ms といった外れ値(GC か OS のスケジューリングだと思います)も混ざっています。なので外れ値の影響を受けにくい最小値での比較、つまり +10.5% が実態に近いと見ています。
考察:なぜ遅くなるのか
公式ドキュメントによると、RegExpRouter は登録されたルートを「1 本の巨大な正規表現」にまとめ、1 回のマッチングで結果を得ます。TrieRouter は Trie 木を使う実装で、全パターンをサポートする代わりに RegExpRouter ほど速くはない、とされています。
そして SmartRouter のデフォルト構成は [new RegExpRouter(), new TrieRouter()] です。アプリ起動時に速い方から順に試し、扱えるものを選んで以降それを使い続けます。つまり 「速い方を試して、ダメなら全パターン対応の方」 という設計です。
今回はその「ダメなら」の分岐を踏んだので、差が出たこと自体は設計通りです。
正規表現 1 本に畳んである RegExpRouter は、ルートが増えてもマッチングのコストがほとんど変わらないはずです。対して TrieRouter は木を辿るので、パスのセグメント数とルート数に応じてコストが乗ると思います。だとすると、今回測った 7〜10% という差はこのアプリの規模での話で、ルートが数百本あるアプリではもっと開くのではないでしょうか。
逆に言えば、小さいアプリならフォールバックしても体感には出ないはずです。
ただ、7〜10% という数字自体は正直どうでもいいと思っています。問題はルートを 1 本足しただけで無言で切り替わることの方です。ログにも型にも出ません。
そしてこれは「遅くなる」だけの話で終わらない気がしています。SmartRouter が選ぶ Router が変わるということは、6 章で見た通りマッチするハンドラが変わり得るということでもあります。今回は v4 と v5 の比較でそれが出ましたが、同じ v5 の中でもルートの追加順や組み合わせ次第で踏む可能性があるのでは、と疑っています。ここは試しきれていません。
7. 速くなったのか
コアのルーティング性能そのものも測りました。
> 注意:これは app.fetch() を使ったインプロセスのマイクロベンチです。
> HTTP サーバーを立てていないので、実サーバーや Cloudflare Workers 本番の性能は示しません。
> ネットワーク・TLS・プロセス境界のコストは一切含まれていません。
GET /users/:id を 100,000 回(ウォームアップ 20,000 回)、v4 と v5 を交互に 10 ラウンドです。
| 統計量 | v4 | v5 | 差 |
| --- | ---: | ---: | ---: |
| 中央値 | 270.53 ms | 266.50 ms | v5 が 1.5% 速い |
| 平均 | 278.24 ms | 271.85 ms | v5 が 2.3% 速い |
| 最小 | 258.31 ms | 255.59 ms | v5 が 1.1% 速い |
結論は「差は誤差の範囲」です。
どの統計量でも v5 がわずかに速い方向ではあります。でも v4 の最小値(258.31 ms)が v5 の中央値(266.50 ms)より速く、分布が大きく重なっています。この測り方で「v5 は速くなった」と言うのは無理があります。
考察:ラウンド数を書かないベンチマークは読めない
これも正直に書いておきます。5 ラウンドで測ったときは差が 2.9% ありました。10 ラウンドに増やしたら 1.5% に縮みました。
同じコード、同じマシン、同じ日です。ラウンド数を変えただけで結論の見え方が倍違いました。
Router フォールバックの方でも同じことが起きています(3 ラウンドで 3.3%、12 ラウンドで 7.1%)。
マイクロベンチで数 % を語るときは、ラウンド数と分布を出さないと意味がない。今回はそれを身をもって踏みました。この記事で数字を出している箇所には、全部ラウンド数を書いています。
そして「コアのルーティング性能は v4 から据え置き」というのは、悪い結論ではないと思っています。v5 の変更は性能のためのものではないからです。
8. 「軽くなった」は、何が軽くなったのか
v5 ではランタイムアダプターが hono 本体から切り離され、@hono/* として別パッケージになりました。これを「軽量化」と書く記事が出てくると思うので、何が軽くなったのかを分けて測りました。
インストールサイズは確かに減った
npm registry の unpackedSize を引きました。
| | 展開後サイズ | ファイル数 |
| --- | ---: | ---: |
| hono@4.13.13 | 1.23 MB | 574 |
| hono@5.0.0-rc.1 | 0.66 MB | 310 |
| 差 | -46% | -46% |
手元の node_modules を du -sh で測ると 2.8 MB → 1.5 MB で、こちらも -46% でした。
そして Yusuke Wada さんが Hono Conference 2026 の発表で出している数字も、1.29 MB → 0.70 MB の -46%、ファイル数 574 → 310 です。ファイル数は完全に一致しました。サイズの絶対値が少しずれているのは、おそらく参照しているバージョンの違いです。
3 つの独立した測り方で同じ比率が出たので、この数字は信用していいと思います。
でもバンドルサイズは増えた
ここが本題です。esbuild で実際にバンドルして比べました。
| エントリ | v4 | v5 | 差分 |
| --- | ---: | ---: | ---: |
| コアのみ | 18,528 B | 18,830 B | +302 B (+1.6%) |
| アダプター込み | 22,760 B | 22,923 B | +163 B (+0.7%) |
減るどころか増えています。
考えてみれば当たり前で、インストールサイズが減ったのは CJS ビルドを削除したこと、そしてアダプターを別パッケージに出したことの効果です。CJS ビルドはそもそもバンドルには入りません。アダプターも、結局 @hono/bun を入れれば同じコードが入ります。
バンドラが tree-shaking で落としていた分が、パッケージ分割で物理的に分かれただけ。最終的に実行されるコード量は変わりません。
「Hono v5 で軽量化」と書くときは、インストールサイズの話なのかバンドルサイズの話なのかを分けて書く必要があります。 前者は正しく、後者は(少なくとも今回の測定では)間違いです。
9. ESM only と、アダプター分離のまわり
ESM only なのに require() できる
v5 は CJS ビルドを削除して ESM only になりました。公式ガイドは Node.js 22.12 以降を要求しています。
面白いのは、その条件下では require('hono') が動くことです。
| 確認内容 | 結果 |
| --- | --- |
| v4 で require('hono') | OK |
| v5 で require('hono') | OK |
| v4 で import('hono/bun') | 解決できる |
| v5 で import('hono/bun') | ERR_PACKAGE_PATH_NOT_EXPORTED |
Node.js 22.12 以降は ESM を require() できるようになったので、パッケージが ESM only でも require が通ります。公式ガイドにもその旨が書かれていて、実際にその通りでした。
ただし hono/bun のようなサブパスは exports から消えているので、こちらは確実に壊れます。壊れるのは「CJS で読んでいること」ではなく「サブパス import」の方です。
アダプターは 8 つ、すべて 2026-09-28 に公開されている
npm registry で初版の公開日を引きました。
| パッケージ | 初版公開日 |
| --- | --- |
| @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 |
8 つとも同じ日に 1.0.0 が出ています。v5 のために切り出されたパッケージ群です。
> @hono/node-server はこの 8 つに含まれません。 初版が 2023-01-07 で、v4 の時代からずっと別パッケージでした。v5 で切り出されたものと混同しないよう注意してください。
アダプター分離と、サプライチェーンの話
アダプター分離について「依存を減らしてサプライチェーン攻撃の対象を狭める狙いもある」という見方を聞いていたので、調べてみました。
Yusuke Wada さんは 2026 年 6 月 23 日、GMO Flatt Security の勉強会「OSS開発者は今何をするべきか? ソフトウェアサプライチェーン侵害対策を考える」で「Honoでのサプライチェーン侵害対策 〜 3つのライブラリに学ぶ」という発表をしています。
題材になっている 3 つは axios・TanStack・Mastra で、それぞれ攻撃経路が違います。
| ライブラリ | 攻撃経路 |
| --- | --- |
| axios | ソーシャルエンジニアリング。メンテナの端末にマルウェアを入れて認証情報を奪う |
| TanStack | pull_request_target のキャッシュ汚染(Pwn Request)。PR がマージされなくても成立する |
| Mastra | npm アカウントの乗っ取り |
そして Hono 側の対策として挙がっているものに、「アダプター機能をコアから外す」 が入っています。他にはこういったものです。
- npm の publish を手元から GitHub Actions + OIDC trusted publishing に移す
- pull_request_target は使わない
- Actions を pin して環境を固定する
- Dev Containers と Gitpod の設定を削除する
つまりアダプター分離は、パッケージサイズの話であると同時に、攻撃対象を狭める話でもあるようです。Hono Conference 2026 の発表ではサイズとファイル数の方が前に出ていますが、6 月の時点ではセキュリティの文脈で語られていた、ということになります。
ただし「依存を減らす」ではない
ここでひとつ、自分で確かめて分かったことがあります。
npm registry で hono の dependencies を引くと、こうなっています。
| | runtime dependencies |
| --- | ---: |
| hono@4.13.13 | 0 |
| hono@5.0.0-rc.1 | 0 |
| @hono/bun@1.0.1 | 0(peer に hono のみ) |
参考までに express は 28、axios は 4 です。
Hono は v4 の時点で外部ランタイム依存がゼロでした。 だからアダプター分離で減ったのは依存の数ではありません。v5 でも v4 でもゼロのままです。
減ったのはコアが配るコードの量です。8 章で見た通り、ファイル数が 574 → 310 になりました。
依存ゼロのライブラリにとって、サプライチェーン上の攻撃対象は「自分が配っているコードそのもの」です。そう考えると、アダプターをコアから外すのは、依存グラフを削る話ではなく、1 つのパッケージに同梱して配るコードを半分に削る話として筋が通ります。
10. defineHandler() ―― 「選択肢が少ない」の実物
1 章で書いた「抽象化されていて、選択肢が少ない」が一番はっきり出ているのが、v5 で入った defineHandler() です。experimental 扱いですが、方向性がよく分かるので触ってみました。
一番単純な形がこれです。
import { defineHandler } from 'hono/factory'
app.get('/ping', defineHandler(() => ({ pong: true })))
ping -> 200 {"pong":true}
c.json() を呼んでいません。返した値が Response に変換されます。 オブジェクトなら JSON、文字列や JSX なら HTML、null や undefined なら 204 です。
リクエストとレスポンスの検証を同じ場所に書けます。
app.post(
'/users',
defineHandler({ json: NameSchema })((c, input) => ({
name: input.json.name.toUpperCase(),
}))
)
NameSchema は Standard Schema に準拠していれば何でも渡せます。Zod などがそのまま使えるということです。
検証に失敗するとこう返りました。
schema ng -> 400 {"error":"Validation failed","issues":[{"slot":"json","issues":[{"message":"name must be a string","path":"name"}]}]}
ここで一点ハマりました。Standard Schema ではなく普通の検証関数を渡して、その中で throw すると 400 ではなく 500 になります。
invalid -> 500 Internal Server Error
検証関数も渡せる API なので同じ扱いになると思い込んでいましたが、HTTPException にラップして 400 にしてくれるのは Standard Schema のルートだけでした。400 を返したいなら Standard Schema 形式で返す必要があります。
そして型定義のコメントに、この API の思想がそのまま書かれていました。
> A JSON Response by c.json() is not accepted, because it bypasses the response validation. Return the value instead.
c.json() を返すことを型で禁止しています。 レスポンス検証を迂回できてしまうからです。
「やり方が複数あって、どれを選んでもだいたい動く」から「正しいやり方が 1 つで、他は型で止まる」への移行。1 章で引いた設計目標が、そのまま形になっていると思いました。
なお defineHandler() は experimental です。正式リリースまでに変わる可能性があります。
11. 移行チェックリスト
実測を踏まえて、優先度順に並べました。
まず tsc --strict を通す(型で捕まる)
- c.req.json() の結果に直接プロパティアクセスしている箇所
- const { x } = c.req.query() の分割代入
- c.req.queries() の戻り値を string[] として扱っている箇所
- app.onError((err, c) => ...) を (c) => ... + c.error に書き換え
ここは全部コンパイラが教えてくれます。機械的に潰せます。
次に、型では捕まらない 3 つを潰す
- c.json() に undefined が渡り得る箇所 —— 500 になります。テストがないと気づけません
- onError() / notFound() を複数回登録している箇所 —— 上書きではなく合成されます
- ワイルドカードを含むルート —— /x*/y と /x/y のマッチ結果が変わります
ここが本番です。型チェックを通したから安心、とはなりません。
あわせて /x*/y と /x/* のような競合ルートがないかも見ておくと、Router のフォールバックを避けられます。
import を書き換える
- hono/<runtime> → @hono/<runtime>(8 パッケージ)
- hono/cloudflare-pages は代替なし。Workers への移行が必要
- app.mount() → hono/mount の mount()
- app.fire() → @hono/service-worker の fire(app)
- c.req.routePath / c.req.matchedRoutes → hono/route
- getColorEnabledAsync() → getColorEnabled(c.env)
- toSSG() の各種 Hook オプション → plugins 経由(defaultPlugin() は明示が必要)
細かいところでは UnOfficalStatusCode が UnofficialStatusCode に直っています。v4 からずっと残っていたタイポがここで修正されました。
12. 今回確認できなかったこと
全部は追いきれていません。残したものを書いておきます。
- route() でサブアプリの notFound() を取り込んだときの優先順位。PR 本文には「深くネストしたサブアプリが先に走る」「同じ深さなら登録順」と書かれていますが、実際には動かしていません
- basePath() と onError() のパス解決を組み合わせたときの挙動
- 実 HTTP サーバー経由のベンチマーク。今回は全部 app.fetch() です
- Cloudflare Workers 実環境での計測
- @hono/zod-validator と c.req.valid() の組み合わせ
- Hono CLI 1.0 RC
それから冒頭にも書いた通り、v5 は RC です。 ここに書いた挙動が正式リリースで変わる可能性があります。
13. まとめ
実測して一番強く思ったのは、v5 で壊れるのは v4 が緩かったところだけということでした。
c.req.json() の any も、c.req.query() の string も、型が嘘をついていました。v5 はそれをやめただけで、挙動は 1 ミリも変わっていません(実際、クエリの実行時の値は v4 と v5 で完全に同じでした)。onError() の合成化も、「特別な 1 個の関数」をやめて普通のミドルウェアに揃えた結果です。
方向性としては、1 章で引いた 「抽象化されていて、選択肢が少ない」 で一貫しています。defineHandler() が c.json() を型で拒否するところまで含めて、全部同じ思想でした。
移行コストは正直に言って安くありません。でもその大半は tsc --strict が指してくれます。本当に気をつけるべきは、型でも捕まらない 3 つ —— c.json(undefined)、onError() の合成化、ワイルドカードのマッチ変更です。この 3 つだけは、テストを書くか目で追うしかありません。
性能は据え置きでした。インストールサイズは半分近くになりましたが、バンドルサイズは変わりません。v5 に上げる理由は、速さでも軽さでもなく、API の筋の良さだと思います。
今すぐ本番に入れるかと言われれば、RC なので待ちます。でも手元のプロジェクトを hono@next で一度ビルドしてみるのは、今やる価値があります。型エラーの量を見れば、移行コストがその場で分かるからです。
最後に、正式リリースまでの予想を書いておきます。
ここまで見てきた変更のうち、ひっくり返るとしたら defineHandler() だと思っています。 experimental と明記されている唯一の API で、しかも「検証関数で throw すると 400 ではなく 500」のような、まだ角が取れていない挙動が残っています。逆に onError() の再設計とワイルドカードの統一は、PR で設計が詰められていて実装も入り切っているので、このまま出ると見ています。
それと、移行で一番詰まるのは hono/cloudflare-pages の削除ではないかと予想しています。他の変更は書き換えれば済みますが、これだけは代替がなく、Workers への移行という別作業が発生するからです。型エラーの数で言えば c.req.query() が圧倒的に多いはずですが、あれは機械的に潰せます。時間を食うのは数ではなく、代替のない 1 件の方だと思います。
正式版が出たら答え合わせをします。
僕のプロジェクトは、ちゃんと壊れました。
参考
- Migrating to v5 — Hono — v5 の破壊的変更の一覧。この記事の検証項目はここから取った
- feat!: add routable error and not-found middleware (honojs/hono#5541) — onError() / notFound() 再設計の PR。@ERROR / @NOT_FOUND の実装方針や、サブアプリでの優先順位が書かれている
- Hono Conference 2026 — Yusuke Wada — 「AI のための Hono」という v5 の位置づけと、パッケージサイズの数字
- honojs/hono releases — 5.0.0-rc.0(2026-10-08)と 5.0.0-rc.1(2026-10-10)の公開日
- Honoでのサプライチェーン侵害対策 〜 3つのライブラリに学ぶ — Yusuke Wada — axios / TanStack / Mastra の攻撃経路と、Hono 側の対策。アダプター分離もここに挙がっている
- 「OSS開発者は今何をするべきか?」参加レポート — kouki_engineer — 上の発表を含む勉強会(2026-06-23・GMO Flatt Security 主催)の参加者による記録
- npm registry: hono — dependencies / unpackedSize / fileCount はここから引いた
- Standard Schema — defineHandler() が受け付けるスキーマの共通インターフェース
- Routers — Hono — RegExpRouter / TrieRouter / SmartRouter それぞれの仕組みと、デフォルトの組み合わせ
- Mount Middleware — Hono — 削除された app.mount() の移行先
TypeScript 7 Upgrade Guide: What Breaks, ESLint, Next.js and Real Benchmarks
I tested the TypeScript 7 upgrade and ran into three catches that are easy to miss: removed tsconfig options, a typescript-eslint break, and a side-by-side setup that changes which compiler next build uses. Here is the full guide with the exact errors and fixes.
TypeScript 7 is the native, Go-based rewrite of the TypeScript compiler, and it has been the latest version on npm since July 8, 2026. It type-checks the same code as TypeScript 6 but much faster. On this box Hono's source built 5.2x faster (3.01 s to 0.58 s), with identical JavaScript output.
The upgrade is usually one npm install, with three real catches. Every option deprecated in TypeScript 6 is now a hard error. TypeScript 7 has no programmatic API, so typescript-eslint refuses to run. And the side-by-side workaround quietly changes which compiler next build uses. I hit all three while testing, so this guide gives the exact errors and fixes, cited to the official TypeScript 7 announcement.
What is TypeScript 7?
TypeScript 7 is a faithful port of the TypeScript compiler and language service from TypeScript to Go. It ships as the normal typescript npm package with the same tsc command. Microsoft says it "typically" yields "speedups between 8x and 12x on full builds" through native code and shared-memory multithreading. The type system and emitted JavaScript match TypeScript 6.0.
Key facts at a glance:
| | TypeScript 6.0 | TypeScript 7.0 |
| --- | --- | --- |
| Written in | TypeScript (runs on Node.js) | Go (native binary, multithreaded) |
| npm package | typescript@6 / @typescript/typescript6 | typescript@7 (latest, 7.0.2) |
| Type checking | Reference behavior | Same semantics, with stableTypeOrdering always on |
| Deprecated options | Error, silenced by ignoreDeprecations: "6.0" | Hard error, cannot be silenced |
| Programmatic API | Yes (ts.createProgram, ...) | None yet; planned for 7.1 |
| Editor | Built-in TS server | LSP server, VS Code extension |
The announcement adds that "practically any TypeScript code that compiles cleanly with TypeScript 6.0 (with the stableTypeOrdering flag on, and without any ignoreDeprecations flag set) should compile identically in TypeScript 7.0."
Is TypeScript 7 stable and ready for production?
Yes, for compiling and type-checking. TypeScript 7.0 is a stable release, and the team tested it with companies including Bloomberg, Canva, Figma, Google, Linear, Notion, Sentry, Slack and Vercel. It is not ready for tools that need the compiler API, such as typescript-eslint and Vue, Svelte, Astro or MDX tooling, until TypeScript 7.1 ships a new API.
Quotable results from the announcement:
- Slack's CI type-checking went "from about 7.5 minutes to 1.25 minutes."
- The new language server "reduced failing language server commands by over 80%, and reduced server crashes by over 60%" compared with 6.0.
- Opening a file with an error in the VS Code codebase dropped from about 17.5 seconds to under 1.3 seconds.
The TypeScript 7.1 iteration plan lists "Stabilize API" as its first item. It scheduled the 7.1 beta for October 6 and the stable release for November 24, 2026. On October 17, 2026 npm still had no 7.1 beta, only nightly 7.1.0-dev builds under the next tag, so treat the date as a target.
How much faster is TypeScript 7? (my benchmark)
On an 8-vCPU Linux box, TypeScript 7.0.2 built Hono's source (189 files, about 26,000 lines, declarations plus JavaScript) in a median 0.58 seconds, against 3.01 seconds for TypeScript 6.0.3. That is a 5.2x speedup with 27% less peak memory. Both compilers emitted byte-identical JavaScript for all 188 output files.
The run: tsc -p tsconfig.build.json on Hono at commit 5ca49b0, three runs each, Node.js 24.22.0, measured with os.wait4 for wall time and peak RSS.
| Compiler | Median | Runs | Peak memory |
| --- | --- | --- | --- |
| TypeScript 6.0.3 | 3.01 s | 2.85, 3.01, 3.08 | 528 MB |
| TypeScript 7.0.2 (default, 4 checkers) | 0.58 s | 0.58, 0.50, 0.59 | 383 MB |
| TypeScript 7.0.2 --checkers 8 | 0.61 s | 0.61, 0.60, 0.62 | 416 MB |
| TypeScript 7.0.2 --singleThreaded | 0.98 s | 0.98, 1.03, 0.89 | 250 MB |
!Bar chart of the TypeScript 6 vs 7 benchmark on Hono: TypeScript 6.0.3 3.01 seconds, TypeScript 7.0.2 0.58 seconds, --checkers 8 0.61 seconds, --singleThreaded 0.98 seconds, with peak memory for each
My 5.2x is below Microsoft's 8x to 12x because this project is small. Startup time weighs more on a 3-second build than on VS Code's 125-second one, so expect larger gains on larger codebases. Two things surprised me:
- More checkers didn't help here. --checkers 8 was no faster than the default of 4 and used more memory. The announcement says extra checkers help "larger codebases" at "the cost of increased memory usage."
- Even single-threaded, TypeScript 7 was 3x faster and used half the memory. On small CI runners --singleThreaded or --checkers 1 is a reasonable choice.
Does TypeScript 7 emit the same output?
The JavaScript was identical. The .d.ts files were not: 25 of 188 declaration files differed. In 16 the only change was quote style ('/' instead of "/"). In 9, TypeScript 7 wrote a reference such as toArray: typeof toArray where TypeScript 6 had inlined the full function type:
- toArray: (children: Child) => Child[];
+ toArray: typeof toArray;
These are equivalent types, but if you publish a library, diff your generated declarations or run your API report tool before releasing a version built with TypeScript 7.
What breaks when upgrading to TypeScript 7?
Three things break most often. Every compiler option deprecated in TypeScript 6.0 is now a hard error that ignoreDeprecations no longer silences. The new defaults (types: [], rootDir: "./", strict: true) surface errors in existing configs. And any tool that imports the typescript API fails, because 7.0 ships without one.
Removed tsconfig options
I ran a deliberately old tsconfig.json through both compilers. TypeScript 6 already reports these as errors (TS5101/TS5107, "will stop functioning in TypeScript 7.0"), but you can silence them with "ignoreDeprecations": "6.0". TypeScript 7 reports TS5102/TS5108 and ignores that flag (with it set, 7.0.2 still exited 1):
tsconfig.json(3,15): error TS5108: Option 'target=ES5' has been removed. Please remove it from your configuration.
tsconfig.json(5,25): error TS5108: Option 'moduleResolution=node10' has been removed. Please remove it from your configuration.
tsconfig.json(6,5): error TS5102: Option 'baseUrl' has been removed. Please remove it from your configuration.
Use '"paths": {"*": ["./*"]}' instead.
tsconfig.json(7,24): error TS5090: Non-relative paths are not allowed. Did you forget a leading './'?
tsconfig.json(8,5): error TS5102: Option 'downlevelIteration' has been removed. Please remove it from your configuration.
tsconfig.json(9,24): error TS5108: Option 'esModuleInterop=false' has been removed. Please remove it from your configuration.
The full list of removals and their replacements, from the announcement:
| Removed in TypeScript 7 | Use instead |
| --- | --- |
| target: "es5", downlevelIteration | es2015 or later (a bundler can down-level if you must) |
| moduleResolution: "node" / "node10" / "classic" | "nodenext" (Node) or "bundler" (Vite, Next.js, webpack) |
| module: "amd", "umd", "systemjs", "none" | "esnext" or "preserve" |
| baseUrl | paths with ./-relative entries |
| esModuleInterop: false, allowSyntheticDefaultImports: false | Remove (always on) |
| alwaysStrict: false | Remove (always on) |
| module Foo {} namespace syntax | namespace Foo {} |
| import ... assert { type: "json" } | import ... with { type: "json" } |
| tsc file.ts inside a folder with tsconfig.json | tsc --ignoreConfig file.ts |
New defaults that cause errors
After removing those options, two TypeScript 6 defaults (which 7 keeps) produced new errors:
error TS5011: The common source directory of 'tsconfig.json' is './src'. The 'rootDir' setting must be
explicitly set to this or another path to adjust your output's file layout.
src/index.ts(2,19): error TS2591: Cannot find name 'process'. Do you need to install type definitions for node?
Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.
rootDir now defaults to ./, and types now defaults to [], so @types/* packages are no longer included automatically. This is the config that compiled and ran on TypeScript 7.0.2:
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"outDir": "dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src"]
}
Other defaults to know: strict is true, module is esnext, noUncheckedSideEffectImports is true, and stableTypeOrdering is always on.
Language and JavaScript changes
Template literal inference now keeps Unicode code points whole. This compiles on TypeScript 7 and fails on 6, which also makes it a quick way to prove which compiler a tool is running:
// lib/which-ts.ts — compiles only on TypeScript 7
type HeadTail<S> = S extends `${infer Head}${infer Tail}` ? [Head, Tail] : never
export const check: HeadTail<'😀abc'> = ['😀', 'abc']
TypeScript 6 reports Type '"😀"' is not assignable to type '"�"', because it splits the emoji into two UTF-16 halves.
JSDoc-typed JavaScript is also stricter: @enum, @class, a bare ? type and Closure-style function(string): void are no longer recognized. The CHANGES.md file tracks the full list.
Does typescript-eslint work with TypeScript 7?
No. typescript-eslint 8.71.2 (the latest on October 17, 2026) declares typescript >=4.8.4 <6.1.0 as a peer dependency and throws at startup when it finds TypeScript 7. Because eslint-config-next uses it, npm run lint fails in every Next.js app that upgrades. The fix is to keep TypeScript 6 installed under the typescript name for the API.
This is the exact error from npx eslint . in a Next.js 16.4.2 app after npm i -D typescript@7.0.2:
typescript-eslint does not support TS 7.0.
Please see https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/#running-side-by-side-with-typescript-6.0 to run typescript-eslint using the TS 6 API.
See also https://github.com/typescript-eslint/typescript-eslint/issues/10940 for tracking typescript-eslint's support for TS >=7.1
Oops! Something went wrong! :(
ESLint: 9.39.5
Error: typescript-eslint does not support TS 7.0.
Support is tracked in typescript-eslint issue #10940 and depends on the 7.1 API.
How do I run TypeScript 7 and 6 side by side?
Install TypeScript 7 under an alias such as @typescript/native, and install @typescript/typescript6 under the typescript name. Tools that import typescript (ESLint, Vue, Svelte) get the 6.0 API, while npx tsc runs the TypeScript 7 binary. This is the setup the TypeScript team recommends in its announcement.
{
"devDependencies": {
"@typescript/native": "npm:typescript@^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2"
}
}
What I saw after npm install:
$ npx tsc -v
Version 7.0.2
$ npx tsc6 -v
Version 6.0.3
$ npx eslint .
(no output, exit 0)
The compatibility package ships a tsc6 binary, so the two never fight over the name tsc.
The catch: next build now uses TypeScript 6
With the alias setup, next build type-checks with TypeScript 6, not 7. Next.js resolves the package named typescript, finds the compatibility package and runs its tsc6. I proved it with the emoji file above: npx tsc passed, but next build failed with the TypeScript 6 error:
Running TypeScript ...
lib/which-ts.ts(3,42): error TS2322: Type '"😀"' is not assignable to type '"�"'.
Failed to type check.
So pick one of two setups until typescript-eslint supports 7.1:
| Setup | next build checks with | ESLint (type-aware) | Best for |
| --- | --- | --- | --- |
| A. typescript@7 only | TypeScript 7 (fast) | Broken | Projects that lint with Biome or oxlint, or no type-aware lint |
| B. Alias: typescript = TS 6, @typescript/native = TS 7 | TypeScript 6 | Works | Projects on eslint-config-next |
With setup B, add npx tsc --noEmit as its own CI step to get TypeScript 7's speed for type checking. Next.js's typescript.ignoreBuildErrors would skip the slower check in next build, but only use it if that tsc step blocks the merge, because the Next.js docs call that option "very dangerous" on its own.
!Decision flow for upgrading to TypeScript 7: fix TypeScript 6 deprecations first, then if the project uses typescript-eslint, Vue, Svelte, Astro or MDX tooling, use the alias setup with tsc 7 in CI, otherwise install typescript@7 directly
Does Next.js support TypeScript 7?
Yes. Since Next.js 16.3, next build can type-check with TypeScript 7, and in 16.4 it uses the project's tsc CLI by default (experimental.useTypeScriptCli: true), which works without the compiler API. Install typescript@^7 and it is used automatically. In my test the "Running TypeScript" step dropped from 1,368 ms to 231 ms.
The Next.js 16.3 release post says: "To start using TypeScript 7 for type checking during next build, just bump your project's local dependency." The TypeScript config docs add the trade-off: CLI type checking "prints the native tsc diagnostics" and "does not apply Next.js-specific code frames," and it checks every file your tsconfig.json includes, test files too.
My measurements on the Next.js 16.4.2 app from my use cache guide:
| | TypeScript 5.9.3 (create-next-app default) | TypeScript 7.0.2 |
| --- | --- | --- |
| npx tsc --noEmit | 1.37 s | 0.38 s |
| next build "Finished TypeScript in" | 1,368 ms | 231 ms |
| Real type error caught by next build | Yes | Yes (TS2322, build fails) |
create-next-app 16.4.2 still writes "typescript": "^5" into new projects, so new apps start on 5.9 until you upgrade. The app's default tsconfig (moduleResolution: "bundler", no baseUrl) needed no changes for TypeScript 7.
TypeScript 7 upgrade checklist
To upgrade safely, get to TypeScript 6.0 with zero deprecation errors first, then install TypeScript 7, fix the types and rootDir defaults, decide how to keep the 6.0 API for ESLint and framework tooling, and add TypeScript 7 to CI. Library authors should also diff their generated .d.ts files before publishing.
1. Upgrade to TypeScript 6.0 and remove ignoreDeprecations. Fix every TS5101/TS5107 deprecation error; each becomes a hard TypeScript 7 error.
2. Replace baseUrl with ./-relative paths, and moduleResolution: "node" with "bundler" or "nodenext".
3. Install TypeScript 7: npm i -D typescript@^7 (or the alias setup if step 6 applies).
4. Add "types": ["node"] (plus jest, bun and so on) if globals like process stop resolving.
5. Set rootDir when tsconfig.json sits above src and you emit files.
6. Check API-dependent tools: typescript-eslint, eslint-config-next, Vue/Volar, Svelte, Astro, MDX, Angular templates. If you use any, keep typescript pointed at @typescript/typescript6.
7. Add npx tsc --noEmit to CI and check that it runs version 7 (npx tsc -v).
8. Tune parallelism: try --checkers 1 or --singleThreaded on small CI runners and higher --checkers on large monorepos, and measure.
9. Libraries: diff .d.ts output between 6 and 7 before publishing.
10. Editor: install the TypeScript 7 VS Code extension, and use "Disable TypeScript 7 Language Server" for Vue or Svelte projects.
Common TypeScript 7 upgrade mistakes
The most common mistakes are upgrading straight from 5.x without passing through 6.0's deprecation errors, assuming the alias setup makes next build use TypeScript 7, forgetting the new types: [] default, and trusting a passing build while lint is silently skipped. Each produces either a confusing error or a slower build than you expected.
| Mistake | Symptom | Fix |
| --- | --- | --- |
| Skipping TypeScript 6 | Wall of TS5102/TS5108 errors | Upgrade to 6.0 first and clear its deprecations |
| Keeping ignoreDeprecations: "6.0" | Still errors on 7 | Remove it and fix the options |
| typescript@7 with eslint-config-next | "typescript-eslint does not support TS 7.0" | Alias setup B |
| Alias setup but expecting fast next build | Build still uses tsc6 | Run npx tsc --noEmit in CI as well |
| Relying on automatic @types/* | Cannot find name 'process' (TS2591) | "types": ["node"] |
| Upgrading a Vue, Svelte or Astro app's editor | Lost template type checking | Keep TypeScript 6 for the editor |
| Publishing a library without checking .d.ts | Different declaration text | Diff and review before release |
If you deploy through Docker, rebuild without cache after changing the compiler. My Next.js Dockerfile guide shows a multi-stage build where npm run build, and with it the type check, runs in the builder stage.
Related: Prisma 8 needs TypeScript 5.9 or newer, and its client type-checked cleanly on TypeScript 7.0.2 in my test. See Prisma 8 vs Prisma 7: should you upgrade?
Related: on NestJS, TypeScript 7 isn't usable through the Nest CLI yet: nest build asks for TypeScript 6 until 7.1. Plain tsc 7 works. Details in my NestJS 12 migration guide.
Related: Oxlint's type-aware mode runs on typescript-go too. On this site's code it matched all 80 typescript-eslint findings in 1.29 s vs 20.5 s; details in Oxlint vs Biome vs ESLint.
Key takeaways
- TypeScript 7 is the native Go port of TypeScript, latest on npm since July 8, 2026 (7.0.2), with the same type system as 6.0.
- On Hono's source it built 5.2x faster than TypeScript 6 on my 8-vCPU box, with identical JavaScript output.
- Everything deprecated in TypeScript 6 is a hard error in 7: es5, node10 resolution, baseUrl, AMD/UMD modules, esModuleInterop false.
- New defaults bite: types is [] and rootDir is ./, so add "types": ["node"] and an explicit rootDir.
- typescript-eslint does not support TypeScript 7; keep @typescript/typescript6 under the typescript name until 7.1 ships an API.
- Next.js 16.3+ type-checks with TypeScript 7 automatically, but the alias setup makes next build use TypeScript 6, so run npx tsc in CI too.
- Library authors should diff .d.ts output, which can differ in quote style and typeof references.
---
Originally published on fabwaseem.com.
honoの最後のPRがマージされた
ありがとうyusukebeさん
hono#5436 t.co
13 likes · 1 repost · 336 views
HonoConference は Your City で開催可能です!
興味を持たれた方は下記のURLをチェックしてみてください!
次のHonoConfを開催するのはあなた!! かもしれない!!!
honojs/honoconf
#honoconf
18 likes · 5 reposts · 514 views
PreparedRegExpRouterはこれ
github.com/honojs/hono/blob/main/src/router/reg-e…
#honoconf
16 likes · 4 reposts · 1,601 views
@ashunar0 まじ同じでうけるw
ここでやってるので、コメントください!(もうすぐマージしちゃうかもだけど
hono#5531
3 likes · 1 repost · 1,490 views
Mentions
Speeding up your React Router server-side-rendered (SSR) app with Hono If you're running a React Router app in framework mode, you get SSR out of the box and for production it hands you @react-router/serve, a small server built on top of Express. While it does the job, Express carries a bit of history (and weight) with it, so if you're the kind of person who wants a leaner server, or just more control over what happens around your app, there's a really nice alternative that drops in with almost no glue. Hono (and its Node server) The createRequestHandler from react-router gives you a function that takes a standard web Request and resolves to a standard web Response, that's the whole contract. It doesn't care about Node, Express, or any particular server, it just wants a Request and it'll give you back a Response. Hono is built on those exact same web standards and, inside its handler, context.req.raw is already a web Request. This means that passing a request from Hono to React Router is basically one line, with no conversions, no bridging and no fuss. Since Hono itself doesn't listen on a port, it's just a handler, to actually run it on Node you'll have to bring in @hono/node-server, whose serve function takes your app.fetch and wires it up to Node's built-in http server. Install both (npm i hono @hono/node-server) before this: import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { createRequestHandler, type ServerBuild } from "react-router"; const build = (await import("./build/server/index.js")) as ServerBuild; // This one here is the output of your bundle const handler = createRequestHandler(build); const app = new Hono(); app.use(async (context) => handler(context.req.raw)); serve({ fetch: app.fetch, port: 3000 }); That's it. serve spins up the Node HTTP server, every request gets turned into a web Request, Hono passes it to React Router, and the Response goes back out. Squeezing the last bit of performance If you must make every request count and you're not going to use Hono's ecosystem (middlewares and everything else), you can improve your performance by dropping the app entirely and relying only on a bare request listener. import { createServer, type RequestListener } from "node:http"; import { getRequestListener } from "@hono/node-server"; import { createRequestHandler, type ServerBuild } from "react-router"; const build = (await import("./build/server/index.js")) as ServerBuild; const handler = createRequestHandler(build); const server = createServer(getRequestListener((request) => handler(request) as RequestListener)); server.listen({ port: 3000 }); Why not use Fastify? Fastify is fast, mature, and plenty of people already reach for it by default. It's a great framework overall. Why not then? The difference comes down to what each one speaks natively. Fastify is built on Node's own HTTP primitives, IncomingMessage and ServerResponse (you can reach them as request.raw and reply.raw). It doesn't hand you a web Request, and it doesn't want a web Response back. React Router deals in nothing but web Request and Response, so to marry the two, you would end up having to write an adapter to make it work. Since you already have Node's own server available, you don't need to put Fastify in its place, because it would generate unnecessary overhead. Where Fastify really shines is when the server itself is the product: JSON APIs with schema-based validation and its blazing fast JSON stringify serialization. With that said, for an SSR app where your whole "API" is basically "give this web request to React Router and return the response", Hono removes an entire category of mess you'd have to maintain. Before wrapping everything up, there are two things worth taking into consideration: - React Router's loadContext is not being provided to the handler function in the examples, so you'll need to write your own logic to provide it. I suggest taking a look at how @react-router/express, or some other library, does it; - There are a lot of things going on in your server when you call react-router-serve, that's why I also recommend taking a look at how it goes, so you don't miss anything you didn't know you needed. Wrap-up Swapping to Hono doesn't ask much of you. React Router already talks in web Request and Response, Hono does too, and @hono/node-server handles the Node part for you. You start with a handful of lines and from there you may add (or not) port handling, static serving, logging requests, etc. It's the performance boost your SSR app could be looking for. --- <small><small><small>This article was created with the help of AI because, while I'm good at being a software engineer (I hope so), I suck at being a writer. On the other hand, the silly jokes and wordplay are completely mine because I'm really good at those. Also, the content comes from things I've done or learned, has been verified and is edited to be straight to the point, since AI loves to stall, but I hate it.</small></small></small>
Agent DX 〜 AI時代にフレームワークHonoは必要か AIがユーザーになりました。我々は人間向けの開発者ではなく、AI向けの開発者体験を考えなくてはいけません。 我々は人間です。雰囲気で「フレームワークは必要ない」「フレームワークは必要」と言っても、AIの意見ではありません。人間の意見です。説得力がありません。我々人間はAIのことをもっと知らなくてはいけません。 そこで、Honoでは「Agent DX」というプロジェクトを立ち上げて、AI向けの開発者体験をちゃんと測りました。以下3つのカンファレンスでAgent DXについて話しました。 AI時代のWebフレームワークはどこへ行く? - フロントエンドカンファレンス福岡 2026 Hono in this era - Hono Conference 2026 Hono: Web application primitives for the AI era - Cloudflare Connect 2026 今回はそれらを記事化してみます。 Agent DXとは AIエージェントがHonoを使うときの開発者体験 = DXを測って改善するプロジェクトです。リポジトリは以下です。 honojs/agent-dx また、結果を以下のページで公開しています。 agent-dx.hono.dev AI Evalsで測る 測り方には「AI Evals」という手法を使います。AI Evalsは固有名詞ではなく、一般的な名称でNext.jsやAngularといったフレームワークも取り入れています。 Next.js:Next.js Agent Evals 実際の Next.js のタスクをエージェントに解かせて採点している。AGENTS.md の効果も公開している Angular:web-codegen-scorer 生成したコードのビルド成功、実行時エラー、アクセシビリティなどを評価する Vercel:@vercel/agent-eval 評価の基盤。ドキュメントや MCP の有無、モデルの違いを比べられる どのようにやるかというと、エージェントにタスクを解かせて採点します。 エージェントにプロンプトを与える エージェントがサンドボックスでコードを書く 書かれたコードをテスト、もしくは静的解析する(採点する) 成功率・トークン・時間を記録して比べる ポイントは採点です。中身は普通のテストと型チェック、静的解析で、LLMには判定させません。テストはエージェントからは見えないところに置いておきます。 ちなみに、AI Evalsの環境として、Astroチームが作っているAIエージェント向けフレームワークのFlueを使っています。 実験は3つやりました。 Adoption → AIはHonoを使うのか? Value → Honoは必要か? Practical → AI向けにHonoを使いやすくするには? 一つずつ見ていきます。 実験 1. Adoption 〜 AIはHonoを使うのか? AIがコードを書く時、フレームワーク、とりわけHonoを使うのか?というのをAI Evalsで測ってみました。 まっさらなディレクトリでエージェントにアプリを作らせます。どのフレームワークを使うか指示は出しません。そして、書いたコードを静的解析して、どのフレームワークを使ったかを見ます。 プロンプトは以下のようなものです。 Create a Cloudflare Workers application in TypeScript. It should respond to GET /health with the JSON {"ok":true}. ランタイムを複数用意しました。Cloudflare Workers、Bun、Node.js、Denoの4つです。シナリオは、GET /health だけの最小アプリから、バリデーション付きのTODO APIまで用意しました。モデルはClaude Haiku 4.5とClaude Opus 5を使います。 まずはHaiku 4.5の結果です。 ヒントなしだと、フレームワークを使いません。素のハンドラを書きます。 次に 「フレームワークを使って」と明示的に言うと、ランタイムごとの定番を選びます。Cloudflare WorkersはHono、BunはElysia、Node.jsはExpress、DenoはOakとなります。 次にOpus 5の結果です。 ヒントなしだと、やはりフレームワークを使いません。「フレームワークを使って」と言うと、Node.jsでもHonoを選びます。 分かったことは以下となります。 ヒントなしだと、AIは素のハンドラを書く 賢いモデルほどHonoを選ぶ 面白いですね! package.json に hono があると さらに別の計測もしました。package.json を置いて、dependencies に hono を入れておきます。 package.json { "dependencies": { "hono": "^4" } } すると、AIはNode.jsでもHonoを使いました! Cloudflare Workers → 5/5 Hono Node.js → 5/5 Hono つまり、AIは自分からはフレームワークを使わないですが、ヒントがあればHonoを選ぶことが分かります。 実験 2. Value 〜 Honoは必要か? AIにとってフレームワークは必要か?とりわけHonoは必要なのか?をAI Evalsで測ってみました。 同じタスクを二通りでやらせます。 素の fetch ハンドラで書く Honoで書く タスクは2つ用意しました。 shop API → 商品・カート・チェックアウト。状態を持つ複雑なAPI notes API → ログイン・Bearer認証・CORS・JSONエラー。認証まわり 結果はこちらです。Haiku 4.5で各10回です。 成功率が上がる → shop API で2/10 → 4/10 コードが30〜40%減る トークンが15〜30%減る AIが書いても、フレームワークの恩恵はあることが分かります。正確になる、書く量が減る、少ないトークンで書ける。つまり、AIにもライブラリは要るのです! 以上、Adoption、Valueの結果は、測るまでは分からなかった・確信を持てなかったことなので、測ることは大事です! 実験 3. Practical 〜 AI向けにHonoを使いやすくするには? ここまでで、きっかけがあればAIはHonoを使うこと、そしてHonoを使う効果があることが分かりました。では、AIにとって、さらにHonoを使いやすくするにはどうすればいいか?最後の実験です。 エージェントにタスクを与えて、結果を見ます。そしてHonoの側を変えて、また試します。これをくり返しました。 中でも印象的だったのが、「開発サーバー」についてです。 エージェントが開発サーバーを立ち上げる Honoには、プロジェクトのテンプレートを作る create-hono があります。そのCloudflare Workersのテンプレートをそのまま使い、エンドポイントを追加してもらいました。AGENTS.mdは入れていません。 結果です。Haiku 4.5、Sonnet 5、Opus 5で各5回です。 15/15 が wrangler dev を起動 サーバーを立ち上げたまま、bashごとハングするケースもあった 例えばSonnet 5は成功率20%。5本中4本が600秒ハング 原因は package.json にある dev スクリプトでした。つまり、エージェントは、開発の最中、このdevコマンドを実行し自らハングしてしまっていたのです。 package.json { "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy --minify" } } Hono CLI これを解決しようと開発してるのがHono CLIです。 honojs/cli Hono CLIは、Honoのアプリを作るのを助けるAIエージェント向けのCLIです。いくつかコマンドがあります。 hono request ではサーバーを立ち上げずに、JavaScriptのRequest/Responseレイヤーで、Honoのアプリにリクエストを送ることができます。 オプションはcurlライクなものです。 GET / hono request / POST /users hono request -X POST -d '{"name":"Momo"}' /users また、複数のリクエストを一度に送って、pass / failを判定する hono batch もあります。 CLIとSkillsを入れると 同じタスクで、Hono CLIとSkillsを使わせました。 15/15、100%開発サーバーを立てない 初手で hono batch 全モデルで成功率100%、約30秒 Sonnet 5の結果です。 Opusに「カウンターをつくって」→「パスワードつけて」と頼んだ例もあります。1回ずつです。 CLIなし → 13ターン・70秒。他の開発サーバーまで止めた CLIあり → 8ターン・39秒。サーバーは立てない どちらも正しく動く つまり、Hono CLIを使うと、次のようになります。 サーバーを立てない → ハングしない、他のサーバーを止めない サーバーを待たない → 速いこともある Hono CLIがツール(hono requestなど)を提供しているので、エージェントが一から作成する必要がない Hono CLIのおかげで、Honoを良くすることができます! ちなみに、Hono CLI(現在1.0のRC版が出てます)とSkillsは以下で試せます。 npm i -D @hono/cli@next npx skills add honojs/skills 詳しくは以下のページを参考にしてください。 hono.dev 人間向けのDXとAI向けのDX AIが開発サーバーを立てたのには、きっかけがありました。package.jsonの scriptsにあるdevコマンドです。 よく考えると、dev コマンドは人間にとってのDXなのだと思います。打ちやすくて、みんなが知っているショートカット。そして、人間は開発サーバーを立ち上げブラウザでlocalhost:8787等にアクセスし結果を確認します。 一方で、AIに開発サーバーは必要ないかもしれません。リクエストを投げて、レスポンスを確かめられれば十分です。 今回の開発サーバーの件を振り返ると、人間にとってのDXを向上させるものが、AIにとっては裏目に出たと言えます。 つまり、人間向けのDXとAI向けのDXがあるのです! まとめ Agent DXで分かったことをまとめます。 Adoption → きっかけがあればHonoを使う。package.json にあれば100% Value → AIが書いても、Honoの恩恵はある Practical → package.jsonにあるdevスクリプトでエージェントがハングする。Hono CLIとSkillsで防げる AI時代にフレームワークHonoは必要か?その答えは⋯ AI時代でもHonoはあった方がいい!そして、AI Evalsを使ってAI向けのDXは改善していくことができるのです!
PRを無効化する代わりに、「Issue立ててください、forkしたブランチがあるならそのアドレスを載せてください、それを取り込むこともあるよ」って書いたら、forkブランチのアドレスばかりになったので、その記載も消した。 hono#5593
I Tried Hono v5 RC: What Changed from v4, and What Breaks? 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 - Migrating to v5 — Hono — Official breaking-change guide; the source for my test cases. - feat!: add routable error and not-found middleware (honojs/hono#5541) — onError() / notFound() redesign, reserved route methods, and sub-app priority rules. - Hono Conference 2026 — Yusuke Wada — The “Hono for AI” framing and package size figures. - honojs/hono releases — Release dates of 5.0.0-rc.0 (2026-10-08) and 5.0.0-rc.1 (2026-10-10). - Hono Supply Chain Compromise Countermeasures — Yusuke Wada — Case studies of axios, TanStack, and Mastra; adapter extraction as a mitigation. (Japanese) - “What Should OSS Developers Do Now?” meetup report — kouki_engineer — Attendee report on the June 23, 2026 GMO Flatt Security meetup. (Japanese) - npm registry: hono — Source for dependencies, unpackedSize, and fileCount. - Standard Schema — Common schema interface accepted by defineHandler(). - Routers — Hono — RegExpRouter, TrieRouter, SmartRouter, and default selection. - Mount Middleware — Hono — Migration destination for the removed app.mount().
Hono v5 RC を触ってみた。v4 から何が変わり、何が壊れるのか > あなたのアプリ、v5 にしたら壊れますか。僕のは壊れました。 この記事の結論 Hono v5 の RC が出たので、v4 と v5 の環境を別々に作って、同じコードを両方で動かして差分を取りました。型チェック・ランタイム挙動・ルーティング・性能・サイズの 5 系統を実測しています。 - 型で捕まる変更は、全部ちゃんと型で捕まった。 移行の第一歩は tsc --strict を通すことです - 本当に怖いのは、型でも捕まらない 3 つ。 c.json(undefined)、onError() の合成化、ワイルドカードのマッチ変更です - 「軽くなった」はインストールサイズの話。 最終バンドルはむしろ 302 バイト増えました 実測した環境です。 | 項目 | 値 | | --- | --- | | 検証日 | 2026-10-18 | | v4 | hono@4.13.13 | | v5 | hono@5.0.0-rc.1 | | Node.js | v24.22.0 | | TypeScript | 7.0.2 | | マシン | Apple M4 (arm64) | 実験コードと生データはこちらです: github.com/tanahiro2010/slides/tree/main/slides/s… そして、この記事で一番伝えたいことを先に書いておきます。 v5 で壊れるのは、v4 が緩かったところです。 c.req.json() が any を返していたのも、c.req.query() が undefined を隠していたのも、onError() が黙って上書きされていたのも、全部 v4 の側が嘘をついていた箇所でした。v5 はそれを正直にしただけです。だから移行は面倒ですが、面倒の量と設計の筋の良さは別の話です。 > この記事は Hono v5 の RC を触ってみた記録です。対象は、Hono を実際に使っていて「v5 にしたら何が壊れるのか」を知りたい人。 > v5 は RC 版です。正式リリース時に仕様が変わる可能性があります。 この記事の内容は 2026-10-18 時点のものです。 --- 1. v5 は「AI のための Hono」だった 破壊的変更の一覧から入る前に、v5 が何を目指しているのかを先に書きます。ここを押さえると、個々の変更が全部同じ方向を向いていることが分かります。 作者の Yusuke Wada さんは、Hono Conference 2026 の発表で v5 を 「AI のための Hono」 と表現しています。 そして AI に優しい API の設計目標として、こう挙げています。 > 抽象化されていて、選択肢が少ない これが v5 を読み解く鍵でした。 c.req.json() が any を返さなくなったのも、onError() が「1 つだけ登録できる特別な関数」から「普通のミドルウェア」になったのも、やることは同じです。特殊な抜け道を潰して、選択肢を減らしている。 人間なら「ここは any だけど実際は文字列が来るから大丈夫」と空気を読めます。LLM にコードを書かせると、その空気は読まれません。型が嘘をついていれば、嘘のまま出力されます。 そう考えると、この記事で後から出てくる変更が全部つながります。 2. 検証環境を作る v4 と v5 の依存が混ざらないよう、ディレクトリを完全に分けました。 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 # 5.0.0-rc.1 が入る npm install -D typescript tsx @types/node RC は next タグで配布されています。npm install hono@next が公式ガイドの案内する方法です。 実際に入ったバージョンがこれです。 $ (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 検証はすべて app.request() と app.fetch() で行っています。HTTP サーバーを立てていないので、ランタイムアダプターは不要です。 3. 型が厳しくなった c.req.json() が any から unknown へ v4 ではこのコードが型チェックを通ります。 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(), // ← 通ってしまう }) }) name に数値が送られてくれば実行時に落ちます。型は何も守ってくれていません。 v5 で同じコードを tsc --strict に通すと、こうなります。 src/typecheck-json.ts(9,11): error TS18046: 'body' is of type 'unknown'. 絞り込むまでプロパティに触れなくなりました。 直し方は 3 つあります。公式ガイドが最初に挙げているのは型引数を渡す形です。 const body = await c.req.json<{ name: string }>() ただしこれは実行時検証ではありません。 型引数は「こう来るはずだ」という宣言でしかなく、嘘をついても通ります。unknown 化の意図は「中身を確かめてから触れ」ということなので、これで黙らせるのは本末転倒だと個人的には思っています。 素直なのは型ガードを書く形です。これは v5 でも型エラーになりませんでした。 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() }) }) 実務的には 3 つ目、バリデーターを使って c.req.valid() で受けるのが正解です。公式ガイドもそう案内しています。 c.req.query() の分割代入が一斉に赤くなる こちらの方が影響範囲は広いと思います。 const { name } = c.req.query() const upper: string = name.toUpperCase() const all = c.req.queries() const tags: string[] = all.tag v4 は通ります。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[]'. キー無しで呼んだときの戻り値が Record<string, string | undefined> になったので、分割代入している箇所は全部赤くなります。 考察:実行時の値は何も変わっていない ここが面白いところでした。GET /q?a=1&a=2 を投げて、実際に返ってくる値を比べてみます。 | | v4 | v5 | | --- | --- | --- | | c.req.query() | {"a":"1"} | {"a":"1"} | | c.req.queries() | {"a":["1","2"]} | {"a":["1","2"]} | 完全に同じです。 つまり v5 は挙動を変えていません。v4 でも存在しないキーを引けば undefined が返っていたのに、型が string だと言い張っていただけです。 変わったのは型が正直になったことだけ。赤くなった箇所は、もともと v4 の時点でバグり得た箇所です。 4. エラーハンドリングが再設計された ここが v5 で一番面白い変更です。 何が変わったのか v4 の onError() はこうでした。 app.onError((err, c) => { console.error(err.message) return c.json({ error: 'Internal Server Error' }, 500) }) アプリ全体に 1 つだけ。シグネチャは (err, c)。 v5 はこうなります。 // パススコープ付きのエラーミドルウェア app.onError('/api/*', async (c, next) => { console.error(c.error?.message) c.header('X-Error-Handler', 'api') await next() }) // パス省略 = 現在の basePath 内の '*' に登録 app.onError((c) => { return c.json({ error: 'Internal Server Error' }, 500) }) シグネチャが (c, next) になり、エラーは c.error から読みます。そしてパスを指定でき、複数登録できるようになりました。 この変更を入れた 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 と @NOT_FOUND という予約メソッドで Router に登録されています。つまり内部的には、エラーハンドラも普通のルートと同じ仕組みで引かれています。 notFound() も同じ形になりました。 app.notFound( '/api/*', async (c, next) => { c.header('x-not-found', 'api') await next() }, (c) => c.text('API resource not found', 404) ) 計測:パススコープは本当に効くのか 実際に動かして確かめました。 | リクエスト | X-Error-Handler | status | body | | --- | --- | --- | --- | | /api/error | api | 500 | {"error":"Internal Server Error"} | | /other/error | (なし) | 500 | {"error":"Internal Server Error"} | 効いています。/api/* のときだけヘッダが付き、最終的なレスポンスは共通のハンドラが返しています。 同じことを v4 でやろうとすると、こうなります。 this.errorHandler is not a function v4 の onError() は引数を 1 つしか取らないので、パス文字列をハンドラとして保存してしまい、リクエストが来た時点で落ちます。登録時には何も言われません。notFound() も同様に TypeError: onNotFound is not a function になりました。 ハマりポイント①:上書きではなく、合成になった ここが移行で一番事故ると思います。 onError() を 2 回登録して、どちらが走るか記録しました。 | | 実行されたハンドラ | レスポンス | | --- | --- | --- | | v4 | ["second"] | second | | v5 | ["first", "second"] | second | v4 は後勝ちの上書き、v5 は合成です。 v4 では「あとから登録したものが前のを潰す」という前提のコードが書けました。それを v5 に持っていくと、両方走ります。 1 つ目がレスポンスを返した場合はチェーンが止まります。 | | 実行されたハンドラ | レスポンス | | --- | --- | --- | | v5 | ["first"] | first | next() を呼べば次に渡り、レスポンスを返せばそこで終わる。普通のミドルウェアと同じ規則です。 そしてこれは型エラーになりません。 シグネチャさえ (c, next) に直せば tsc は通ります。実際に動かすまで気づけない類の変更です。 ハマりポイント②:v4 の書き方のまま動かすと、意味不明なエラーが出る v4 スタイルのハンドラを v5 で動かすとどうなるか、気になったので試しました。 まず型チェックはちゃんと止めてくれます。 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'. 第 1 引数が Context、第 2 引数が Next になったことがそのまま出ています。ここで気づけます。 問題は、これを @ts-expect-error で黙らせて実行したときです。 Context is not finalized. Did you forget to return a Response object or `await next()`? 原因を全く指していません。 実際に観測された引数はこうでした。 { "firstArgCtor": "Context", "firstArgIsContext": true, "secondArgType": "function" } v4 のつもりで c として触っているものが next 関数なので、c.text() が存在せず例外になり、結果として Context が確定しないまま終わる。だからこのメッセージが出ます。 型エラーを握りつぶすと、デバッグがかなり遠回りになるパターンです。 非 Error を throw したときの扱い v4 では、ハンドラから文字列やオブジェクトを throw すると app.fetch() の外まで漏れていました。 v5 では Error にラップされて onError に流れます。 | | v4 | v5 | | --- | --- | --- | | throw 'oops' | 外に漏れる | message: 'oops' / cause: 'oops' | | throw { code: 42 } | 外に漏れる | message: '' / cause: { code: 42 } | 注意したいのはオブジェクトを投げた場合です。message は空文字になります。 元の値は cause にしか入りません。 app.onError((c) => { console.log(c.error?.message) // オブジェクトを投げると '' になる console.log(c.error?.cause) // { code: 42 } return c.json({ error: 'wrapped' }, 500) }) ログに c.error.message だけ出していると、何も分からないログが残ります。cause を見る必要があります。 サードパーティのライブラリが変なものを投げてきても拾えるようになったのは、素直にありがたい変更です。 5. 型では捕まらない、一番危ない変更 c.json(undefined) の挙動が変わりました。 | | v4 | v5 | | --- | --- | --- | | c.json(undefined) | 200 / ボディ長 0 | 500 | | c.json(() => 1) | 200 / ボディ空 | 500 | v5 で実際に投げられている例外がこれです。 TypeError: Value is not JSON serializable Response.json() と同じ挙動に揃えた、というのが公式の説明です。筋は通っています。 問題は、これが型エラーにならないことです。 c.json() に渡す値が条件分岐の結果 undefined になり得るコードは、tsc を何回通しても何も言われません。onError を登録していなければ組み込みハンドラが 500 を返すので、本番で初めて気づくことになります。 この記事で挙げる移行チェックの中で、優先度は一番高いと思っています。テストがないと検出できません。 具体的にどこで踏むかを予想しておきます。一番ありそうなのは、DB から引いた 1 件を c.json() にそのまま渡しているコードです。 const user = await db.query.users.findFirst({ where: eq(users.id, id) }) return c.json(user) // 見つからなければ undefined v4 では 404 を返すつもりが 200 で空ボディ、という見落としがそのまま通っていました。v5 はこれが 500 になります。つまり v5 で 500 が出る箇所は、v4 の時点ですでにバグっていた箇所だと思います。壊れたのではなく、隠れていたバグが表に出るだけ、という見方をしています。 もうひとつありそうなのが、void を返す関数の戻り値をそのまま渡しているケースです。こちらは型が void なので tsc が止めてくれる可能性もありますが、any を経由していると抜けます。 6. ワイルドカードのマッチングが統一された v4 は Router ごとにバラバラだった v4 で同じルートを 4 つの Router に登録して、マッチするかどうかを総当たりしました。 | ルート | パス | 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 | ✅ | ✅ | ✅ | ✅ | 6 ケース中 3 ケースで結果が割れています。 /wild/*/card に至っては、TrieRouter だけが /wild//card にマッチします。Router を切り替えた瞬間にルーティングが変わるということです。 v5 は完全に一致した 同じ表を v5 で取り直しました。 | ルート | パス | 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 | ✅ | ✅ | ✅ | ✅ | 4 つとも一致しました。 公式ドキュメントが示している表とも完全に同じです。 規則はこうなりました。 - セグメント末尾の * はワイルドカード。何にもマッチしなくてよい - セグメント途中の *(/x*y など)はワイルドカードではない - セグメントが * だけなら、空でない 1 セグメントにマッチ v4 から挙動が変わったのは /x*/y と /x/y(全 Router で ❌ → ✅)、それと /x*y と /xay(RegExp 系で ✅ → ❌)です。ここも型では捕まりません。 ハマりポイント③:ルートを 1 行足すと、Router が入れ替わる ここが今回の検証で一番面白かったところです。 /x*/y と /x/* を同時に登録すると、v5 の RegExpRouter はこれを処理できません。 UnsupportedPathError: /x/* そして SmartRouter は、RegExpRouter が投げたこの例外を受けて TrieRouter にフォールバックします。 実際に、アプリが最終的にどの Router を選んだかを見てみました。 | 登録したルート | v5 で選ばれた Router | | --- | --- | | /x/* のみ | SmartRouter + RegExpRouter | | /x*/y + /x/* | SmartRouter + TrieRouter | ルートを 1 行足しただけで、実行時に選ばれる Router が変わりました。 しかも同じ競合ルートを v4 と v5 で引き比べると、マッチするハンドラ自体が違います。 | | v4 | v5 | | --- | --- | --- | | /x/y へのリクエスト | /x/* の方 | /x*/y の方 | ルーティングの結果が静かに変わるので、型でもテストでも気づきにくい変更です。 計測:フォールバックのコスト Router が変わるなら性能も変わるはずなので、測りました。GET /users/:id を 50,000 回、12 ラウンドです。 | | Router | 中央値 | 平均 | 最小 | | --- | --- | ---: | ---: | ---: | | 競合なし | SmartRouter + RegExpRouter | 138.68 ms | 152.28 ms | 132.35 ms | | 競合あり | SmartRouter + TrieRouter | 148.57 ms | 163.99 ms | 146.25 ms | | 差 | | +7.1% | +7.7% | +10.5% | 中央値・平均・最小のすべてで、フォールバック側が遅くなりました。 ひとつ正直に書いておくと、これは測り直した値です。 最初に 3 ラウンドで測ったときは差が 3.3% しか出ず、再実行したら 8.1% になりました。ラウンド数が足りず、結論が安定していませんでした。 12 ラウンドに増やしたあとの生データには 287 ms や 271 ms といった外れ値(GC か OS のスケジューリングだと思います)も混ざっています。なので外れ値の影響を受けにくい最小値での比較、つまり +10.5% が実態に近いと見ています。 考察:なぜ遅くなるのか 公式ドキュメントによると、RegExpRouter は登録されたルートを「1 本の巨大な正規表現」にまとめ、1 回のマッチングで結果を得ます。TrieRouter は Trie 木を使う実装で、全パターンをサポートする代わりに RegExpRouter ほど速くはない、とされています。 そして SmartRouter のデフォルト構成は [new RegExpRouter(), new TrieRouter()] です。アプリ起動時に速い方から順に試し、扱えるものを選んで以降それを使い続けます。つまり 「速い方を試して、ダメなら全パターン対応の方」 という設計です。 今回はその「ダメなら」の分岐を踏んだので、差が出たこと自体は設計通りです。 正規表現 1 本に畳んである RegExpRouter は、ルートが増えてもマッチングのコストがほとんど変わらないはずです。対して TrieRouter は木を辿るので、パスのセグメント数とルート数に応じてコストが乗ると思います。だとすると、今回測った 7〜10% という差はこのアプリの規模での話で、ルートが数百本あるアプリではもっと開くのではないでしょうか。 逆に言えば、小さいアプリならフォールバックしても体感には出ないはずです。 ただ、7〜10% という数字自体は正直どうでもいいと思っています。問題はルートを 1 本足しただけで無言で切り替わることの方です。ログにも型にも出ません。 そしてこれは「遅くなる」だけの話で終わらない気がしています。SmartRouter が選ぶ Router が変わるということは、6 章で見た通りマッチするハンドラが変わり得るということでもあります。今回は v4 と v5 の比較でそれが出ましたが、同じ v5 の中でもルートの追加順や組み合わせ次第で踏む可能性があるのでは、と疑っています。ここは試しきれていません。 7. 速くなったのか コアのルーティング性能そのものも測りました。 > 注意:これは app.fetch() を使ったインプロセスのマイクロベンチです。 > HTTP サーバーを立てていないので、実サーバーや Cloudflare Workers 本番の性能は示しません。 > ネットワーク・TLS・プロセス境界のコストは一切含まれていません。 GET /users/:id を 100,000 回(ウォームアップ 20,000 回)、v4 と v5 を交互に 10 ラウンドです。 | 統計量 | v4 | v5 | 差 | | --- | ---: | ---: | ---: | | 中央値 | 270.53 ms | 266.50 ms | v5 が 1.5% 速い | | 平均 | 278.24 ms | 271.85 ms | v5 が 2.3% 速い | | 最小 | 258.31 ms | 255.59 ms | v5 が 1.1% 速い | 結論は「差は誤差の範囲」です。 どの統計量でも v5 がわずかに速い方向ではあります。でも v4 の最小値(258.31 ms)が v5 の中央値(266.50 ms)より速く、分布が大きく重なっています。この測り方で「v5 は速くなった」と言うのは無理があります。 考察:ラウンド数を書かないベンチマークは読めない これも正直に書いておきます。5 ラウンドで測ったときは差が 2.9% ありました。10 ラウンドに増やしたら 1.5% に縮みました。 同じコード、同じマシン、同じ日です。ラウンド数を変えただけで結論の見え方が倍違いました。 Router フォールバックの方でも同じことが起きています(3 ラウンドで 3.3%、12 ラウンドで 7.1%)。 マイクロベンチで数 % を語るときは、ラウンド数と分布を出さないと意味がない。今回はそれを身をもって踏みました。この記事で数字を出している箇所には、全部ラウンド数を書いています。 そして「コアのルーティング性能は v4 から据え置き」というのは、悪い結論ではないと思っています。v5 の変更は性能のためのものではないからです。 8. 「軽くなった」は、何が軽くなったのか v5 ではランタイムアダプターが hono 本体から切り離され、@hono/* として別パッケージになりました。これを「軽量化」と書く記事が出てくると思うので、何が軽くなったのかを分けて測りました。 インストールサイズは確かに減った npm registry の unpackedSize を引きました。 | | 展開後サイズ | ファイル数 | | --- | ---: | ---: | | hono@4.13.13 | 1.23 MB | 574 | | hono@5.0.0-rc.1 | 0.66 MB | 310 | | 差 | -46% | -46% | 手元の node_modules を du -sh で測ると 2.8 MB → 1.5 MB で、こちらも -46% でした。 そして Yusuke Wada さんが Hono Conference 2026 の発表で出している数字も、1.29 MB → 0.70 MB の -46%、ファイル数 574 → 310 です。ファイル数は完全に一致しました。サイズの絶対値が少しずれているのは、おそらく参照しているバージョンの違いです。 3 つの独立した測り方で同じ比率が出たので、この数字は信用していいと思います。 でもバンドルサイズは増えた ここが本題です。esbuild で実際にバンドルして比べました。 | エントリ | v4 | v5 | 差分 | | --- | ---: | ---: | ---: | | コアのみ | 18,528 B | 18,830 B | +302 B (+1.6%) | | アダプター込み | 22,760 B | 22,923 B | +163 B (+0.7%) | 減るどころか増えています。 考えてみれば当たり前で、インストールサイズが減ったのは CJS ビルドを削除したこと、そしてアダプターを別パッケージに出したことの効果です。CJS ビルドはそもそもバンドルには入りません。アダプターも、結局 @hono/bun を入れれば同じコードが入ります。 バンドラが tree-shaking で落としていた分が、パッケージ分割で物理的に分かれただけ。最終的に実行されるコード量は変わりません。 「Hono v5 で軽量化」と書くときは、インストールサイズの話なのかバンドルサイズの話なのかを分けて書く必要があります。 前者は正しく、後者は(少なくとも今回の測定では)間違いです。 9. ESM only と、アダプター分離のまわり ESM only なのに require() できる v5 は CJS ビルドを削除して ESM only になりました。公式ガイドは Node.js 22.12 以降を要求しています。 面白いのは、その条件下では require('hono') が動くことです。 | 確認内容 | 結果 | | --- | --- | | v4 で require('hono') | OK | | v5 で require('hono') | OK | | v4 で import('hono/bun') | 解決できる | | v5 で import('hono/bun') | ERR_PACKAGE_PATH_NOT_EXPORTED | Node.js 22.12 以降は ESM を require() できるようになったので、パッケージが ESM only でも require が通ります。公式ガイドにもその旨が書かれていて、実際にその通りでした。 ただし hono/bun のようなサブパスは exports から消えているので、こちらは確実に壊れます。壊れるのは「CJS で読んでいること」ではなく「サブパス import」の方です。 アダプターは 8 つ、すべて 2026-09-28 に公開されている npm registry で初版の公開日を引きました。 | パッケージ | 初版公開日 | | --- | --- | | @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 | 8 つとも同じ日に 1.0.0 が出ています。v5 のために切り出されたパッケージ群です。 > @hono/node-server はこの 8 つに含まれません。 初版が 2023-01-07 で、v4 の時代からずっと別パッケージでした。v5 で切り出されたものと混同しないよう注意してください。 アダプター分離と、サプライチェーンの話 アダプター分離について「依存を減らしてサプライチェーン攻撃の対象を狭める狙いもある」という見方を聞いていたので、調べてみました。 Yusuke Wada さんは 2026 年 6 月 23 日、GMO Flatt Security の勉強会「OSS開発者は今何をするべきか? ソフトウェアサプライチェーン侵害対策を考える」で「Honoでのサプライチェーン侵害対策 〜 3つのライブラリに学ぶ」という発表をしています。 題材になっている 3 つは axios・TanStack・Mastra で、それぞれ攻撃経路が違います。 | ライブラリ | 攻撃経路 | | --- | --- | | axios | ソーシャルエンジニアリング。メンテナの端末にマルウェアを入れて認証情報を奪う | | TanStack | pull_request_target のキャッシュ汚染(Pwn Request)。PR がマージされなくても成立する | | Mastra | npm アカウントの乗っ取り | そして Hono 側の対策として挙がっているものに、「アダプター機能をコアから外す」 が入っています。他にはこういったものです。 - npm の publish を手元から GitHub Actions + OIDC trusted publishing に移す - pull_request_target は使わない - Actions を pin して環境を固定する - Dev Containers と Gitpod の設定を削除する つまりアダプター分離は、パッケージサイズの話であると同時に、攻撃対象を狭める話でもあるようです。Hono Conference 2026 の発表ではサイズとファイル数の方が前に出ていますが、6 月の時点ではセキュリティの文脈で語られていた、ということになります。 ただし「依存を減らす」ではない ここでひとつ、自分で確かめて分かったことがあります。 npm registry で hono の dependencies を引くと、こうなっています。 | | runtime dependencies | | --- | ---: | | hono@4.13.13 | 0 | | hono@5.0.0-rc.1 | 0 | | @hono/bun@1.0.1 | 0(peer に hono のみ) | 参考までに express は 28、axios は 4 です。 Hono は v4 の時点で外部ランタイム依存がゼロでした。 だからアダプター分離で減ったのは依存の数ではありません。v5 でも v4 でもゼロのままです。 減ったのはコアが配るコードの量です。8 章で見た通り、ファイル数が 574 → 310 になりました。 依存ゼロのライブラリにとって、サプライチェーン上の攻撃対象は「自分が配っているコードそのもの」です。そう考えると、アダプターをコアから外すのは、依存グラフを削る話ではなく、1 つのパッケージに同梱して配るコードを半分に削る話として筋が通ります。 10. defineHandler() ―― 「選択肢が少ない」の実物 1 章で書いた「抽象化されていて、選択肢が少ない」が一番はっきり出ているのが、v5 で入った defineHandler() です。experimental 扱いですが、方向性がよく分かるので触ってみました。 一番単純な形がこれです。 import { defineHandler } from 'hono/factory' app.get('/ping', defineHandler(() => ({ pong: true }))) ping -> 200 {"pong":true} c.json() を呼んでいません。返した値が Response に変換されます。 オブジェクトなら JSON、文字列や JSX なら HTML、null や undefined なら 204 です。 リクエストとレスポンスの検証を同じ場所に書けます。 app.post( '/users', defineHandler({ json: NameSchema })((c, input) => ({ name: input.json.name.toUpperCase(), })) ) NameSchema は Standard Schema に準拠していれば何でも渡せます。Zod などがそのまま使えるということです。 検証に失敗するとこう返りました。 schema ng -> 400 {"error":"Validation failed","issues":[{"slot":"json","issues":[{"message":"name must be a string","path":"name"}]}]} ここで一点ハマりました。Standard Schema ではなく普通の検証関数を渡して、その中で throw すると 400 ではなく 500 になります。 invalid -> 500 Internal Server Error 検証関数も渡せる API なので同じ扱いになると思い込んでいましたが、HTTPException にラップして 400 にしてくれるのは Standard Schema のルートだけでした。400 を返したいなら Standard Schema 形式で返す必要があります。 そして型定義のコメントに、この API の思想がそのまま書かれていました。 > A JSON Response by c.json() is not accepted, because it bypasses the response validation. Return the value instead. c.json() を返すことを型で禁止しています。 レスポンス検証を迂回できてしまうからです。 「やり方が複数あって、どれを選んでもだいたい動く」から「正しいやり方が 1 つで、他は型で止まる」への移行。1 章で引いた設計目標が、そのまま形になっていると思いました。 なお defineHandler() は experimental です。正式リリースまでに変わる可能性があります。 11. 移行チェックリスト 実測を踏まえて、優先度順に並べました。 まず tsc --strict を通す(型で捕まる) - c.req.json() の結果に直接プロパティアクセスしている箇所 - const { x } = c.req.query() の分割代入 - c.req.queries() の戻り値を string[] として扱っている箇所 - app.onError((err, c) => ...) を (c) => ... + c.error に書き換え ここは全部コンパイラが教えてくれます。機械的に潰せます。 次に、型では捕まらない 3 つを潰す - c.json() に undefined が渡り得る箇所 —— 500 になります。テストがないと気づけません - onError() / notFound() を複数回登録している箇所 —— 上書きではなく合成されます - ワイルドカードを含むルート —— /x*/y と /x/y のマッチ結果が変わります ここが本番です。型チェックを通したから安心、とはなりません。 あわせて /x*/y と /x/* のような競合ルートがないかも見ておくと、Router のフォールバックを避けられます。 import を書き換える - hono/<runtime> → @hono/<runtime>(8 パッケージ) - hono/cloudflare-pages は代替なし。Workers への移行が必要 - app.mount() → hono/mount の mount() - app.fire() → @hono/service-worker の fire(app) - c.req.routePath / c.req.matchedRoutes → hono/route - getColorEnabledAsync() → getColorEnabled(c.env) - toSSG() の各種 Hook オプション → plugins 経由(defaultPlugin() は明示が必要) 細かいところでは UnOfficalStatusCode が UnofficialStatusCode に直っています。v4 からずっと残っていたタイポがここで修正されました。 12. 今回確認できなかったこと 全部は追いきれていません。残したものを書いておきます。 - route() でサブアプリの notFound() を取り込んだときの優先順位。PR 本文には「深くネストしたサブアプリが先に走る」「同じ深さなら登録順」と書かれていますが、実際には動かしていません - basePath() と onError() のパス解決を組み合わせたときの挙動 - 実 HTTP サーバー経由のベンチマーク。今回は全部 app.fetch() です - Cloudflare Workers 実環境での計測 - @hono/zod-validator と c.req.valid() の組み合わせ - Hono CLI 1.0 RC それから冒頭にも書いた通り、v5 は RC です。 ここに書いた挙動が正式リリースで変わる可能性があります。 13. まとめ 実測して一番強く思ったのは、v5 で壊れるのは v4 が緩かったところだけということでした。 c.req.json() の any も、c.req.query() の string も、型が嘘をついていました。v5 はそれをやめただけで、挙動は 1 ミリも変わっていません(実際、クエリの実行時の値は v4 と v5 で完全に同じでした)。onError() の合成化も、「特別な 1 個の関数」をやめて普通のミドルウェアに揃えた結果です。 方向性としては、1 章で引いた 「抽象化されていて、選択肢が少ない」 で一貫しています。defineHandler() が c.json() を型で拒否するところまで含めて、全部同じ思想でした。 移行コストは正直に言って安くありません。でもその大半は tsc --strict が指してくれます。本当に気をつけるべきは、型でも捕まらない 3 つ —— c.json(undefined)、onError() の合成化、ワイルドカードのマッチ変更です。この 3 つだけは、テストを書くか目で追うしかありません。 性能は据え置きでした。インストールサイズは半分近くになりましたが、バンドルサイズは変わりません。v5 に上げる理由は、速さでも軽さでもなく、API の筋の良さだと思います。 今すぐ本番に入れるかと言われれば、RC なので待ちます。でも手元のプロジェクトを hono@next で一度ビルドしてみるのは、今やる価値があります。型エラーの量を見れば、移行コストがその場で分かるからです。 最後に、正式リリースまでの予想を書いておきます。 ここまで見てきた変更のうち、ひっくり返るとしたら defineHandler() だと思っています。 experimental と明記されている唯一の API で、しかも「検証関数で throw すると 400 ではなく 500」のような、まだ角が取れていない挙動が残っています。逆に onError() の再設計とワイルドカードの統一は、PR で設計が詰められていて実装も入り切っているので、このまま出ると見ています。 それと、移行で一番詰まるのは hono/cloudflare-pages の削除ではないかと予想しています。他の変更は書き換えれば済みますが、これだけは代替がなく、Workers への移行という別作業が発生するからです。型エラーの数で言えば c.req.query() が圧倒的に多いはずですが、あれは機械的に潰せます。時間を食うのは数ではなく、代替のない 1 件の方だと思います。 正式版が出たら答え合わせをします。 僕のプロジェクトは、ちゃんと壊れました。 参考 - Migrating to v5 — Hono — v5 の破壊的変更の一覧。この記事の検証項目はここから取った - feat!: add routable error and not-found middleware (honojs/hono#5541) — onError() / notFound() 再設計の PR。@ERROR / @NOT_FOUND の実装方針や、サブアプリでの優先順位が書かれている - Hono Conference 2026 — Yusuke Wada — 「AI のための Hono」という v5 の位置づけと、パッケージサイズの数字 - honojs/hono releases — 5.0.0-rc.0(2026-10-08)と 5.0.0-rc.1(2026-10-10)の公開日 - Honoでのサプライチェーン侵害対策 〜 3つのライブラリに学ぶ — Yusuke Wada — axios / TanStack / Mastra の攻撃経路と、Hono 側の対策。アダプター分離もここに挙がっている - 「OSS開発者は今何をするべきか?」参加レポート — kouki_engineer — 上の発表を含む勉強会(2026-06-23・GMO Flatt Security 主催)の参加者による記録 - npm registry: hono — dependencies / unpackedSize / fileCount はここから引いた - Standard Schema — defineHandler() が受け付けるスキーマの共通インターフェース - Routers — Hono — RegExpRouter / TrieRouter / SmartRouter それぞれの仕組みと、デフォルトの組み合わせ - Mount Middleware — Hono — 削除された app.mount() の移行先
TypeScript 7 Upgrade Guide: What Breaks, ESLint, Next.js and Real Benchmarks I tested the TypeScript 7 upgrade and ran into three catches that are easy to miss: removed tsconfig options, a typescript-eslint break, and a side-by-side setup that changes which compiler next build uses. Here is the full guide with the exact errors and fixes. TypeScript 7 is the native, Go-based rewrite of the TypeScript compiler, and it has been the latest version on npm since July 8, 2026. It type-checks the same code as TypeScript 6 but much faster. On this box Hono's source built 5.2x faster (3.01 s to 0.58 s), with identical JavaScript output. The upgrade is usually one npm install, with three real catches. Every option deprecated in TypeScript 6 is now a hard error. TypeScript 7 has no programmatic API, so typescript-eslint refuses to run. And the side-by-side workaround quietly changes which compiler next build uses. I hit all three while testing, so this guide gives the exact errors and fixes, cited to the official TypeScript 7 announcement. What is TypeScript 7? TypeScript 7 is a faithful port of the TypeScript compiler and language service from TypeScript to Go. It ships as the normal typescript npm package with the same tsc command. Microsoft says it "typically" yields "speedups between 8x and 12x on full builds" through native code and shared-memory multithreading. The type system and emitted JavaScript match TypeScript 6.0. Key facts at a glance: | | TypeScript 6.0 | TypeScript 7.0 | | --- | --- | --- | | Written in | TypeScript (runs on Node.js) | Go (native binary, multithreaded) | | npm package | typescript@6 / @typescript/typescript6 | typescript@7 (latest, 7.0.2) | | Type checking | Reference behavior | Same semantics, with stableTypeOrdering always on | | Deprecated options | Error, silenced by ignoreDeprecations: "6.0" | Hard error, cannot be silenced | | Programmatic API | Yes (ts.createProgram, ...) | None yet; planned for 7.1 | | Editor | Built-in TS server | LSP server, VS Code extension | The announcement adds that "practically any TypeScript code that compiles cleanly with TypeScript 6.0 (with the stableTypeOrdering flag on, and without any ignoreDeprecations flag set) should compile identically in TypeScript 7.0." Is TypeScript 7 stable and ready for production? Yes, for compiling and type-checking. TypeScript 7.0 is a stable release, and the team tested it with companies including Bloomberg, Canva, Figma, Google, Linear, Notion, Sentry, Slack and Vercel. It is not ready for tools that need the compiler API, such as typescript-eslint and Vue, Svelte, Astro or MDX tooling, until TypeScript 7.1 ships a new API. Quotable results from the announcement: - Slack's CI type-checking went "from about 7.5 minutes to 1.25 minutes." - The new language server "reduced failing language server commands by over 80%, and reduced server crashes by over 60%" compared with 6.0. - Opening a file with an error in the VS Code codebase dropped from about 17.5 seconds to under 1.3 seconds. The TypeScript 7.1 iteration plan lists "Stabilize API" as its first item. It scheduled the 7.1 beta for October 6 and the stable release for November 24, 2026. On October 17, 2026 npm still had no 7.1 beta, only nightly 7.1.0-dev builds under the next tag, so treat the date as a target. How much faster is TypeScript 7? (my benchmark) On an 8-vCPU Linux box, TypeScript 7.0.2 built Hono's source (189 files, about 26,000 lines, declarations plus JavaScript) in a median 0.58 seconds, against 3.01 seconds for TypeScript 6.0.3. That is a 5.2x speedup with 27% less peak memory. Both compilers emitted byte-identical JavaScript for all 188 output files. The run: tsc -p tsconfig.build.json on Hono at commit 5ca49b0, three runs each, Node.js 24.22.0, measured with os.wait4 for wall time and peak RSS. | Compiler | Median | Runs | Peak memory | | --- | --- | --- | --- | | TypeScript 6.0.3 | 3.01 s | 2.85, 3.01, 3.08 | 528 MB | | TypeScript 7.0.2 (default, 4 checkers) | 0.58 s | 0.58, 0.50, 0.59 | 383 MB | | TypeScript 7.0.2 --checkers 8 | 0.61 s | 0.61, 0.60, 0.62 | 416 MB | | TypeScript 7.0.2 --singleThreaded | 0.98 s | 0.98, 1.03, 0.89 | 250 MB | !Bar chart of the TypeScript 6 vs 7 benchmark on Hono: TypeScript 6.0.3 3.01 seconds, TypeScript 7.0.2 0.58 seconds, --checkers 8 0.61 seconds, --singleThreaded 0.98 seconds, with peak memory for each My 5.2x is below Microsoft's 8x to 12x because this project is small. Startup time weighs more on a 3-second build than on VS Code's 125-second one, so expect larger gains on larger codebases. Two things surprised me: - More checkers didn't help here. --checkers 8 was no faster than the default of 4 and used more memory. The announcement says extra checkers help "larger codebases" at "the cost of increased memory usage." - Even single-threaded, TypeScript 7 was 3x faster and used half the memory. On small CI runners --singleThreaded or --checkers 1 is a reasonable choice. Does TypeScript 7 emit the same output? The JavaScript was identical. The .d.ts files were not: 25 of 188 declaration files differed. In 16 the only change was quote style ('/' instead of "/"). In 9, TypeScript 7 wrote a reference such as toArray: typeof toArray where TypeScript 6 had inlined the full function type: - toArray: (children: Child) => Child[]; + toArray: typeof toArray; These are equivalent types, but if you publish a library, diff your generated declarations or run your API report tool before releasing a version built with TypeScript 7. What breaks when upgrading to TypeScript 7? Three things break most often. Every compiler option deprecated in TypeScript 6.0 is now a hard error that ignoreDeprecations no longer silences. The new defaults (types: [], rootDir: "./", strict: true) surface errors in existing configs. And any tool that imports the typescript API fails, because 7.0 ships without one. Removed tsconfig options I ran a deliberately old tsconfig.json through both compilers. TypeScript 6 already reports these as errors (TS5101/TS5107, "will stop functioning in TypeScript 7.0"), but you can silence them with "ignoreDeprecations": "6.0". TypeScript 7 reports TS5102/TS5108 and ignores that flag (with it set, 7.0.2 still exited 1): tsconfig.json(3,15): error TS5108: Option 'target=ES5' has been removed. Please remove it from your configuration. tsconfig.json(5,25): error TS5108: Option 'moduleResolution=node10' has been removed. Please remove it from your configuration. tsconfig.json(6,5): error TS5102: Option 'baseUrl' has been removed. Please remove it from your configuration. Use '"paths": {"*": ["./*"]}' instead. tsconfig.json(7,24): error TS5090: Non-relative paths are not allowed. Did you forget a leading './'? tsconfig.json(8,5): error TS5102: Option 'downlevelIteration' has been removed. Please remove it from your configuration. tsconfig.json(9,24): error TS5108: Option 'esModuleInterop=false' has been removed. Please remove it from your configuration. The full list of removals and their replacements, from the announcement: | Removed in TypeScript 7 | Use instead | | --- | --- | | target: "es5", downlevelIteration | es2015 or later (a bundler can down-level if you must) | | moduleResolution: "node" / "node10" / "classic" | "nodenext" (Node) or "bundler" (Vite, Next.js, webpack) | | module: "amd", "umd", "systemjs", "none" | "esnext" or "preserve" | | baseUrl | paths with ./-relative entries | | esModuleInterop: false, allowSyntheticDefaultImports: false | Remove (always on) | | alwaysStrict: false | Remove (always on) | | module Foo {} namespace syntax | namespace Foo {} | | import ... assert { type: "json" } | import ... with { type: "json" } | | tsc file.ts inside a folder with tsconfig.json | tsc --ignoreConfig file.ts | New defaults that cause errors After removing those options, two TypeScript 6 defaults (which 7 keeps) produced new errors: error TS5011: The common source directory of 'tsconfig.json' is './src'. The 'rootDir' setting must be explicitly set to this or another path to adjust your output's file layout. src/index.ts(2,19): error TS2591: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig. rootDir now defaults to ./, and types now defaults to [], so @types/* packages are no longer included automatically. This is the config that compiled and ran on TypeScript 7.0.2: { "compilerOptions": { "target": "es2022", "module": "nodenext", "outDir": "dist", "rootDir": "./src", "types": ["node"] }, "include": ["src"] } Other defaults to know: strict is true, module is esnext, noUncheckedSideEffectImports is true, and stableTypeOrdering is always on. Language and JavaScript changes Template literal inference now keeps Unicode code points whole. This compiles on TypeScript 7 and fails on 6, which also makes it a quick way to prove which compiler a tool is running: // lib/which-ts.ts — compiles only on TypeScript 7 type HeadTail<S> = S extends `${infer Head}${infer Tail}` ? [Head, Tail] : never export const check: HeadTail<'😀abc'> = ['😀', 'abc'] TypeScript 6 reports Type '"😀"' is not assignable to type '"�"', because it splits the emoji into two UTF-16 halves. JSDoc-typed JavaScript is also stricter: @enum, @class, a bare ? type and Closure-style function(string): void are no longer recognized. The CHANGES.md file tracks the full list. Does typescript-eslint work with TypeScript 7? No. typescript-eslint 8.71.2 (the latest on October 17, 2026) declares typescript >=4.8.4 <6.1.0 as a peer dependency and throws at startup when it finds TypeScript 7. Because eslint-config-next uses it, npm run lint fails in every Next.js app that upgrades. The fix is to keep TypeScript 6 installed under the typescript name for the API. This is the exact error from npx eslint . in a Next.js 16.4.2 app after npm i -D typescript@7.0.2: typescript-eslint does not support TS 7.0. Please see https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/#running-side-by-side-with-typescript-6.0 to run typescript-eslint using the TS 6 API. See also https://github.com/typescript-eslint/typescript-eslint/issues/10940 for tracking typescript-eslint's support for TS >=7.1 Oops! Something went wrong! :( ESLint: 9.39.5 Error: typescript-eslint does not support TS 7.0. Support is tracked in typescript-eslint issue #10940 and depends on the 7.1 API. How do I run TypeScript 7 and 6 side by side? Install TypeScript 7 under an alias such as @typescript/native, and install @typescript/typescript6 under the typescript name. Tools that import typescript (ESLint, Vue, Svelte) get the 6.0 API, while npx tsc runs the TypeScript 7 binary. This is the setup the TypeScript team recommends in its announcement. { "devDependencies": { "@typescript/native": "npm:typescript@^7.0.2", "typescript": "npm:@typescript/typescript6@^6.0.2" } } What I saw after npm install: $ npx tsc -v Version 7.0.2 $ npx tsc6 -v Version 6.0.3 $ npx eslint . (no output, exit 0) The compatibility package ships a tsc6 binary, so the two never fight over the name tsc. The catch: next build now uses TypeScript 6 With the alias setup, next build type-checks with TypeScript 6, not 7. Next.js resolves the package named typescript, finds the compatibility package and runs its tsc6. I proved it with the emoji file above: npx tsc passed, but next build failed with the TypeScript 6 error: Running TypeScript ... lib/which-ts.ts(3,42): error TS2322: Type '"😀"' is not assignable to type '"�"'. Failed to type check. So pick one of two setups until typescript-eslint supports 7.1: | Setup | next build checks with | ESLint (type-aware) | Best for | | --- | --- | --- | --- | | A. typescript@7 only | TypeScript 7 (fast) | Broken | Projects that lint with Biome or oxlint, or no type-aware lint | | B. Alias: typescript = TS 6, @typescript/native = TS 7 | TypeScript 6 | Works | Projects on eslint-config-next | With setup B, add npx tsc --noEmit as its own CI step to get TypeScript 7's speed for type checking. Next.js's typescript.ignoreBuildErrors would skip the slower check in next build, but only use it if that tsc step blocks the merge, because the Next.js docs call that option "very dangerous" on its own. !Decision flow for upgrading to TypeScript 7: fix TypeScript 6 deprecations first, then if the project uses typescript-eslint, Vue, Svelte, Astro or MDX tooling, use the alias setup with tsc 7 in CI, otherwise install typescript@7 directly Does Next.js support TypeScript 7? Yes. Since Next.js 16.3, next build can type-check with TypeScript 7, and in 16.4 it uses the project's tsc CLI by default (experimental.useTypeScriptCli: true), which works without the compiler API. Install typescript@^7 and it is used automatically. In my test the "Running TypeScript" step dropped from 1,368 ms to 231 ms. The Next.js 16.3 release post says: "To start using TypeScript 7 for type checking during next build, just bump your project's local dependency." The TypeScript config docs add the trade-off: CLI type checking "prints the native tsc diagnostics" and "does not apply Next.js-specific code frames," and it checks every file your tsconfig.json includes, test files too. My measurements on the Next.js 16.4.2 app from my use cache guide: | | TypeScript 5.9.3 (create-next-app default) | TypeScript 7.0.2 | | --- | --- | --- | | npx tsc --noEmit | 1.37 s | 0.38 s | | next build "Finished TypeScript in" | 1,368 ms | 231 ms | | Real type error caught by next build | Yes | Yes (TS2322, build fails) | create-next-app 16.4.2 still writes "typescript": "^5" into new projects, so new apps start on 5.9 until you upgrade. The app's default tsconfig (moduleResolution: "bundler", no baseUrl) needed no changes for TypeScript 7. TypeScript 7 upgrade checklist To upgrade safely, get to TypeScript 6.0 with zero deprecation errors first, then install TypeScript 7, fix the types and rootDir defaults, decide how to keep the 6.0 API for ESLint and framework tooling, and add TypeScript 7 to CI. Library authors should also diff their generated .d.ts files before publishing. 1. Upgrade to TypeScript 6.0 and remove ignoreDeprecations. Fix every TS5101/TS5107 deprecation error; each becomes a hard TypeScript 7 error. 2. Replace baseUrl with ./-relative paths, and moduleResolution: "node" with "bundler" or "nodenext". 3. Install TypeScript 7: npm i -D typescript@^7 (or the alias setup if step 6 applies). 4. Add "types": ["node"] (plus jest, bun and so on) if globals like process stop resolving. 5. Set rootDir when tsconfig.json sits above src and you emit files. 6. Check API-dependent tools: typescript-eslint, eslint-config-next, Vue/Volar, Svelte, Astro, MDX, Angular templates. If you use any, keep typescript pointed at @typescript/typescript6. 7. Add npx tsc --noEmit to CI and check that it runs version 7 (npx tsc -v). 8. Tune parallelism: try --checkers 1 or --singleThreaded on small CI runners and higher --checkers on large monorepos, and measure. 9. Libraries: diff .d.ts output between 6 and 7 before publishing. 10. Editor: install the TypeScript 7 VS Code extension, and use "Disable TypeScript 7 Language Server" for Vue or Svelte projects. Common TypeScript 7 upgrade mistakes The most common mistakes are upgrading straight from 5.x without passing through 6.0's deprecation errors, assuming the alias setup makes next build use TypeScript 7, forgetting the new types: [] default, and trusting a passing build while lint is silently skipped. Each produces either a confusing error or a slower build than you expected. | Mistake | Symptom | Fix | | --- | --- | --- | | Skipping TypeScript 6 | Wall of TS5102/TS5108 errors | Upgrade to 6.0 first and clear its deprecations | | Keeping ignoreDeprecations: "6.0" | Still errors on 7 | Remove it and fix the options | | typescript@7 with eslint-config-next | "typescript-eslint does not support TS 7.0" | Alias setup B | | Alias setup but expecting fast next build | Build still uses tsc6 | Run npx tsc --noEmit in CI as well | | Relying on automatic @types/* | Cannot find name 'process' (TS2591) | "types": ["node"] | | Upgrading a Vue, Svelte or Astro app's editor | Lost template type checking | Keep TypeScript 6 for the editor | | Publishing a library without checking .d.ts | Different declaration text | Diff and review before release | If you deploy through Docker, rebuild without cache after changing the compiler. My Next.js Dockerfile guide shows a multi-stage build where npm run build, and with it the type check, runs in the builder stage. Related: Prisma 8 needs TypeScript 5.9 or newer, and its client type-checked cleanly on TypeScript 7.0.2 in my test. See Prisma 8 vs Prisma 7: should you upgrade? Related: on NestJS, TypeScript 7 isn't usable through the Nest CLI yet: nest build asks for TypeScript 6 until 7.1. Plain tsc 7 works. Details in my NestJS 12 migration guide. Related: Oxlint's type-aware mode runs on typescript-go too. On this site's code it matched all 80 typescript-eslint findings in 1.29 s vs 20.5 s; details in Oxlint vs Biome vs ESLint. Key takeaways - TypeScript 7 is the native Go port of TypeScript, latest on npm since July 8, 2026 (7.0.2), with the same type system as 6.0. - On Hono's source it built 5.2x faster than TypeScript 6 on my 8-vCPU box, with identical JavaScript output. - Everything deprecated in TypeScript 6 is a hard error in 7: es5, node10 resolution, baseUrl, AMD/UMD modules, esModuleInterop false. - New defaults bite: types is [] and rootDir is ./, so add "types": ["node"] and an explicit rootDir. - typescript-eslint does not support TypeScript 7; keep @typescript/typescript6 under the typescript name until 7.1 ships an API. - Next.js 16.3+ type-checks with TypeScript 7 automatically, but the alias setup makes next build use TypeScript 6, so run npx tsc in CI too. - Library authors should diff .d.ts output, which can differ in quote style and typeof references. --- Originally published on fabwaseem.com.
honoの最後のPRがマージされた ありがとうyusukebeさん hono#5436 t.co
HonoConference は Your City で開催可能です! 興味を持たれた方は下記のURLをチェックしてみてください! 次のHonoConfを開催するのはあなた!! かもしれない!!! honojs/honoconf #honoconf
PreparedRegExpRouterはこれ github.com/honojs/hono/blob/main/src/router/reg-e… #honoconf
@ashunar0 まじ同じでうけるw ここでやってるので、コメントください!(もうすぐマージしちゃうかもだけど hono#5531