メインコンテンツへスキップ

Pages Router / App Router / Hono を1つのNext.jsプロジェクトで比較する

· loading · loading ·
kiitosu
著者
kiitosu
aws community builder. 画像処理やデバイスドライバ、データ基盤構築からWebバックエンドまで、多様な領域に携わってきました。地図解析や地図アプリケーションの仕組みにも経験があり、幅広い技術を活かした開発に取り組んでいます。休日は草野球とランニングを楽しんでいます。
目次

はじめに
#

これまで 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のルートハンドラーに渡されるcContextオブジェクトで、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という名前は慣習で、contextctxでも動きます。これはJavaScriptのコールバックパターンと同じ仕組みで、forEach((item) => ...)itemaddEventListener("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.tsproxy.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.tsproxy.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

関連記事

TypeScript Generatorの3つのメソッド完全理解:next, return, throw
· loading · loading
gRPC - connect - Render でwebサービスを作ってみる:web service with connect
· loading · loading
gRPC - connect - Render でwebサービスを作ってみる:local buf build for web service
· loading · loading