はじめに #
これまで Pages Router を使ったアプリの開発はしてきましたが、App Router や Hono は使ったことがありませんでした。 Pages Router、App Router、Honoの3つのルーターを1つのNext.jsプロジェクト内で同じAPIを実装し、書き方の違いを比較してみました。
今回動作確認した3つのルーター #
- Pages Router — Next.jsの従来のルーティング方式。ファイルの場所がそのままURLになる
- App Router — Next.jsの新しいルーティング方式(Next.js 13以降)。React Server Componentsに対応
- Hono — Web標準ベースの軽量HTTPフレームワーク。Next.jsとは別物だが、Next.jsの中でも動かせる
Pages RouterとApp RouterはNext.js内の選択肢、HonoはNext.jsとは独立したフレームワークという関係です。
プロジェクト構成 #
router-comparison/
├── shared/
│ ├── posts.ts ← 共通のデータ・取得関数
│ └── middleware.ts ← Pages Router用ミドルウェア
├── pages/api/
│ ├── posts.ts ← Pages Router: GET /api/posts
│ └── posts/[id].ts ← Pages Router: GET /api/posts/:id
├── app/api/
│ └── approuter/posts/
│ ├── route.ts ← App Router: GET /api/approuter/posts
│ └── [id]/route.ts ← App Router: GET /api/approuter/posts/:id
├── app/api/hono/
│ └── [[...route]]/route.ts ← Hono: GET /api/hono/posts, /api/hono/posts/:id
└── middleware.ts ← App Router用ミドルウェア
3つのルーターが同じshared/posts.tsを参照することで、ルーティングの書き方の差だけに集中できるようにしました。
リポジトリ: https://github.com/itokohei/router-comparison
準備: 3つのルーターが共有するデータ #
// shared/posts.ts
export type Post = {
id: string;
title: string;
body: string;
};
const POSTS: Post[] = [
{ id: "1", title: "Pages Routerとは", body: "ファイルベースのルーティングを提供する仕組みです。" },
{ id: "2", title: "App Routerとは", body: "React Server Componentsを活用した新しいルーティングです。" },
{ id: "3", title: "Honoとは", body: "Web標準ベースの軽量サーバーフレームワークです。" },
];
export function fetchPosts(): Post[] {
return POSTS;
}
export function fetchPost(id: string): Post | undefined {
return POSTS.find((p) => p.id === id);
}
同じAPIを3通りで書いてみる #
Pages Router #
// pages/api/posts.ts → GET /api/posts
import type { NextApiRequest, NextApiResponse } from "next";
import { fetchPosts } from "@/shared/posts";
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const posts = fetchPosts();
res.status(200).json(posts);
}
Pages RouterではGETを指定していません。詳細は HTTPメソッドの扱い方参照。
App Router #
// app/api/approuter/posts/route.ts → GET /api/approuter/posts
import { fetchPosts } from "@/shared/posts";
export async function GET() {
const posts = fetchPosts();
return Response.json(posts);
}
Hono #
// app/api/hono/[[...route]]/route.ts → GET /api/hono/posts
import { Hono } from "hono";
import { handle } from "hono/vercel";
import { fetchPosts } from "@/shared/posts";
const app = new Hono().basePath("/api/hono");
app.get("/posts", (c) => {
const posts = fetchPosts();
return c.json(posts);
});
export const GET = handle(app);
違い #
| ルート定義 | レスポンス | |
|---|---|---|
| Pages Router | ファイルの場所(pages/api/posts.ts) |
res.status(200).json(posts) |
| App Router | ファイルの場所(app/api/.../route.ts) |
Response.json(posts) |
| Hono | コード(app.get("/posts", ...)) |
c.json(posts) |
App RouterのResponseはWeb標準のグローバルAPIなのでimportが不要。Pages RouterとHonoはフレームワーク独自のオブジェクト経由でレスポンスを返します。
なぜ route.ts なのか
App Routerではファイル名が規約(Convention)で決まっています。
| ファイル名 | 役割 |
|---|---|
page.tsx |
ページ(UI) |
layout.tsx |
レイアウト |
route.ts |
APIルートハンドラー |
route.ts以外の名前ではAPIルートとして認識されません。Honoもroute.tsを使っていますが、これはHonoの要件ではなく、App Routerの上で動いているためです。app/api/hono/[[...route]]/route.tsというパスでNext.jsにルートとして認識させ、内部のルーティングはHonoに委ねています。
一方、Pages Routerにはこのような規約はありません。ファイル名がそのままエンドポイント名になります(posts.ts → /api/posts)。
HTTPメソッドの扱い方
記事一覧APIのコード例ではすべてGETですが、メソッドの判定方法はルーターごとに異なります。
Pages Router — 1つのhandlerがすべてのHTTPメソッドを受け取ります。メソッドの分岐は開発者が手動で書く必要があります。
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") {
res.status(200).json(fetchPosts());
} else {
res.setHeader("Allow", ["GET"]);
res.status(405).json({ error: "Method Not Allowed" });
}
}
App Router — 関数名でHTTPメソッドを指定します。フレームワークが振り分けてくれます。
export async function GET() { /* ... */ }
export async function POST() { /* ... */ }
Hono — メソッドごとにルートを登録します。
app.get("/posts", (c) => { /* ... */ });
app.post("/posts", (c) => { /* ... */ });
| メソッド判定 | 未対応メソッドの扱い | |
|---|---|---|
| Pages Router | req.methodで手動判定 |
書き忘れると全メソッドに応答してしまう |
| App Router | 関数名(GET, POST等)で自動振り分け |
未定義のメソッドには405を返す |
| Hono | app.get(), app.post()等で登録 |
未登録のメソッドには404を返す |
それぞれの方法で値を取得してみる #
Pages Router #
// pages/api/posts/[id].ts
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const id = req.query.id as string;
const post = fetchPost(id);
if (!post) {
return res.status(404).json({ error: "Not found" });
}
res.status(200).json(post);
}
App Router #
// app/api/approuter/posts/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const post = fetchPost(id);
if (!post) {
return Response.json({ error: "Not found" }, { status: 404 });
}
return Response.json(post);
}
Hono #
app.get("/posts/:id", (c) => {
const id = c.req.param("id");
const post = fetchPost(id);
if (!post) {
return c.json({ error: "Not found" }, 404);
}
return c.json(post);
});
Honoの c とは何か
Honoのルートハンドラーに渡されるcはContextオブジェクトで、Honoが自動的に生成して第1引数に注入してくれます。リクエスト情報の取得とレスポンス生成を1つのオブジェクトに統合しています。
app.get("/posts/:id", (c) => {
// リクエスト情報の取得
c.req.param("id") // パスパラメータ
c.req.query("page") // クエリパラメータ
c.req.header("Authorization") // ヘッダー
// レスポンスの生成
return c.json(data); // JSON
return c.text("hello"); // テキスト
return c.redirect("/other"); // リダイレクト
});
Pages Routerではreq(リクエスト)とres(レスポンス)が別々の引数ですが、Honoはcに統合することで一貫したインターフェースを提供しています。
cという名前は慣習で、contextやctxでも動きます。これはJavaScriptのコールバックパターンと同じ仕組みで、forEach((item) => ...)のitemやaddEventListener("click", (event) => ...)のeventと同様に、フレームワークが適切なオブジェクトを渡して関数を呼び出します。
違い #
| パラメータ定義 | 取得方法 | |
|---|---|---|
| Pages Router | ファイル名 [id].ts |
req.query.id as string |
| App Router | フォルダ名 [id]/ |
await params → { id } |
| Hono | コード :id |
c.req.param("id") |
App RouterのparamsがPromiseなのはNext.js 15からの変更。サーバー側の並列処理最適化のための設計。
同じ認証を3通りで実装してみる #
Pages Router — ラッパー関数 #
// shared/middleware.ts
import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
export function withAuth(handler: NextApiHandler) {
return (req: NextApiRequest, res: NextApiResponse) => {
console.log(`[Pages Router] ${req.method} ${req.url}`);
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: "Unauthorized" });
}
return handler(req, res);
};
}
// pages/api/posts.ts — 各ファイルで手動適用
import { withAuth } from "@/shared/middleware";
function handler(req: NextApiRequest, res: NextApiResponse) {
const posts = fetchPosts();
res.status(200).json(posts);
}
export default withAuth(handler);
App Router — Next.jsのmiddleware.ts #
// middleware.ts(プロジェクトルートに配置、Next.jsが自動検出)
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
console.log(`[App Router] ${request.method} ${request.url}`);
const token = request.headers.get("authorization");
if (!token) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.next();
}
export const config = {
matcher: ["/api/approuter/:path*"],
};
middleware.ts から proxy.ts への変更(Next.js 16)
Next.js 16でmiddleware.tsはproxy.tsにリネームされました。機能は同じで、ファイル名と関数名が変わっただけです。
リネームの背景は以下の通りです。
- Expressのmiddlewareとの混同 — Expressのミドルウェアはアプリ内の処理ですが、Next.jsのそれはネットワーク境界で動くもので性質が異なります
- 「proxy」の方が実態に合う — リクエストをアプリに届く前にインターセプトしてリライト・リダイレクトする動きはプロキシそのものです
- セキュリティ上の問題 — 2025年3月に
x-middleware-subrequestヘッダーでミドルウェアの認証チェックをバイパスできる脆弱性(CVE-2025-29927)が発覚し、役割の再整理が行われました
実際にNext.js 16でmiddleware.tsのままpnpm devを実行すると、以下の警告が表示されます。
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead.
Learn more: https://nextjs.org/docs/messages/middleware-to-proxy
なお、middleware.ts(proxy.ts)はプロジェクトルートに1つ配置するもので、App Router専用ではなくPages Routerのリクエストにも適用されます。config.matcherで対象パスを制御します。
NextResponse.next() とは
上のコード例にあるNextResponse.next()は「このリクエストをそのまま次の処理(ルートハンドラー)に渡す」という意味です。middleware(proxy)の中では、リクエストに対して3つの選択肢があります。
export function middleware(request: NextRequest) {
// 1. ブロック — 直接レスポンスを返す(ハンドラーに届かない)
return Response.json({ error: "Unauthorized" }, { status: 401 });
// 2. リダイレクト — 別の場所に転送する
return NextResponse.redirect(new URL("/login", request.url));
// 3. 通過 — 次の処理に進めてよいという合図
return NextResponse.next();
}
Honoのawait next()も同じ考え方で、「次のミドルウェアまたはハンドラーに処理を渡す」ことを意味します。
Hono — app.use() #
app.use("*", async (c, next) => {
console.log(`[Hono] ${c.req.method} ${c.req.url}`);
const token = c.req.header("authorization");
if (!token) {
return c.json({ error: "Unauthorized" }, 401);
}
await next();
});
違い #
| 仕組み | 適用方法 | 複数登録 | |
|---|---|---|---|
| Pages Router | ラッパー関数 | 各ファイルで withAuth(handler) |
ネスト withAuth(withLog(handler)) |
| App Router | Next.js組み込み | ファイル名で自動検出 | 1ファイルのみ、中でif分岐 |
| Hono | .use() |
コードで登録 | 何個でも登録可、上から順に実行 |
なお、Next.jsのmiddleware.tsはPages Routerにも適用可能。今回は3つの違いを際立たせるためにPages Routerではラッパー関数方式を採用した。
まとめ #
| 観点 | Pages Router | App Router | Hono |
|---|---|---|---|
| ルート定義 | ファイルの場所 | ファイルの場所 | コード |
| パラメータ | req.query |
await params |
c.req.param() |
| レスポンス | res.json() |
Response.json() |
c.json() |
| ミドルウェア | 自作ラッパー or middleware.ts | middleware.ts | .use() チェーン |
| 思想 | ファイル = ルート | フォルダ = ルート | コードでルート定義 |
同じ機能を3通りで書いてみました。Pages RouterとApp Routerはファイルシステムに依存した暗黙的なルーティングで、App Routerでは React Server Componentが使える。Honoはコードによる明示的なルーティングを提供する。Pages Routerをあえて選ぶ必要はないと思いますが、App RouterとHonoは要件に合わせて選ぶもののようです。
Reply by Email