Next.js の App Router: Layout、Page、Route

1. 概要

App Router は Next.js 13 以降のデフォルトのルーティング機構で、Pages Router(pages/)を徐々に置き換えています。これは app/ フォルダ内のファイル規約に基づいており、page.tsx がルートを作成し、layout.tsx が共有 UI を作成し、route.ts が API エンドポイントを作成します。この記事を読み終わると、App Router に従って Next.js プロジェクトを組織する方法、ネストされたレイアウトを書く方法、動的ルートとルートハンドラーを作成する方法、そしてこれらのコンポーネントをテストする方法が理解できるでしょう。

2. 基本的なフォルダ構造

App Router は フォルダ = URL セグメント という概念を使用します。app/ 内の各フォルダには、ルートの動作を決定する特殊なファイルを含めることができます。

app/
├── layout.tsx          # ルート layout、<html>、<body> が必須
├── page.tsx             # route "/"
├── about/
│   └── page.tsx         # route "/about"
├── products/
│   ├── layout.tsx       # /products/* 用の独立した layout
│   ├── page.tsx         # route "/products"
│   └── [id]/
│       └── page.tsx     # route "/products/123"
└── api/
    └── orders/
        └── route.ts     # API "/api/orders"
  • layout.tsx は同じレベルまたは下位のすべての page.tsx をラップします。
  • page.tsx は URL の実際のコンテンツをレンダリングするエンドポイントです。
  • route.ts は UI をレンダリングせず、REST エンドポイントのような Response を返します。

注意: フォルダ名が小文字で始まらない場合(例:Products)でも動作しますが、Next.js は大文字と小文字を区別するオペレーティングシステム上での混乱を避けるため、kebab-case または camelCase を一貫して使用することを推奨します。

3. Page — ルートの定義

page.tsx ファイルは、ルートの主な UI となる React コンポーネントをエクスポートします。デフォルトでは Server Component です。

// app/products/page.tsx
import { ProductService } from "@/services/product-service";

export default async function ProductsPage() {
  const products = await ProductService.findAll();

  return (
    <main>
      <h1>商品一覧</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>{product.name} - {product.price}円</li>
        ))}
      </ul>
    </main>
  );
}
  • async function ProductsPage により、Server Component 内で直接 await データを取得できます。useEffect や getServerSideProps は不要です。
  • Next.js はサーバー側で HTML をレンダリングし、コンテンツがすでに含まれた状態でブラウザに送信します — SEO と最初の表示までの時間に優れています。
  • このルートは URL /products に対応します。

3.1. params を使用した動的ルート

// app/products/[id]/page.tsx
import { ProductService } from "@/services/product-service";
import { notFound } from "next/navigation";

type ProductPageProps = {
  params: { id: string };
};

export default async function ProductDetailPage({ params }: ProductPageProps) {
  const product = await ProductService.findById(params.id);

  if (!product) {
    notFound();
  }

  return <h1>{product.name}</h1>;
}
  • フォルダ [id] は動的セグメントを作成し、Next.js は実際の値を params.id に渡します。
  • notFound() は、アプリケーションをクラッシュさせるのではなく、最も近い not-found.tsx ファイルをレンダリングします — これが App Router での標準的な 404 ハンドリング方法です。

4. Layout — 共有 UI

Layout は children prop を受け取り、これはこのルートの子コンポーネントです。同じ layout 内でページ間を移動する際、state が保持されます(例えば、サイドバーは再レンダリングされません)。

// app/products/layout.tsx
export default function ProductsLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="products-layout">
      <aside>商品フィルター</aside>
      <section>{children}</section>
    </div>
  );
}
  • ここで children は app/products/page.tsx または app/products/[id]/page.tsx のコンテンツです。
  • /products から /products/123 に移動する際、<aside> はアンマウント/リマウントされません — これが layout をコンポーネント内で直接インポートするのではなく使用することの主な利点です。

4.1. ルート layout は必須

// app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Shop Nón Lá",
  description: "App Router デモストア",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body>
        <header>Shop Nón Lá</header>
        {children}
      </body>
    </html>
  );
}
  • ルート layout はアプリケーション全体をラップする最上位の layout であるため、<html> と <body> を含む必要があります。
  • export const metadata は <title> と <meta> を宣言的に設定する方法です。Next.js は自動的にこれらを <head> に挿入します — DOM を手動で操作する必要はありません。

注意: app/layout.tsx の作成を忘れた場合、すべてのルートには最低でも root layout が必要なため、Next.js はビルドエラーを報告します。

5. ルートグループと Parallel Segments

コードを整理するためにルートをグループ化したいが、そのグループを URL に表示したくない場合があります。括弧 (name) を使用した ルートグループ を使用してください。

app/
├── (marketing)/
│   ├── layout.tsx       # マーケティングページ用の独立した layout
│   ├── page.tsx         # route "/"
│   └── pricing/
│       └── page.tsx     # route "/pricing"
└── (dashboard)/
    ├── layout.tsx       # ダッシュボード用の独立した layout
    └── settings/
        └── page.tsx     # route "/settings"
  • (marketing) と (dashboard) は URL に表示されず、異なるレイアウトを異なるページグループに適用するのに役立ちます。
  • /pricing と /settings は同じ app/ 配下にありますが、異なるレイアウトを使用します。

6. ルートハンドラー — App Router での API の構築

route.ts ファイルは Pages Router の pages/api/*.ts を置き換え、HTTP メソッド名に従った関数をエクスポートします。

// app/api/orders/route.ts
import { NextRequest, NextResponse } from "next/server";
import { OrderService } from "@/services/order-service";

export async function GET() {
  const orders = await OrderService.findAll();
  return NextResponse.json(orders);
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  const order = await OrderService.create(body);
  return NextResponse.json(order, { status: 201 });
}
  • GET と POST は個別のエクスポートで、Next.js は HTTP メソッドに応じて正しい関数にリクエストをルーティングします。
  • NextResponse.json(...) は Content-Type: application/json を自動的に設定します。手動で JSON.stringify する必要はありません。
  • このエンドポイントは /api/orders で提供され、UI 部分とは完全に独立しています — React をレンダリングしません。

6.1. 動的ルートハンドラー

// app/api/orders/[id]/route.ts
import { NextResponse } from "next/server";
import { OrderService } from "@/services/order-service";

export async function GET(_: Request, { params }: { params: { id: string } }) {
  const order = await OrderService.findById(params.id);

  if (!order) {
    return NextResponse.json({ message: "Order not found" }, { status: 404 });
  }

  return NextResponse.json(order);
}
  • 動的ページと同様に、フォルダ [id] は URL から値を取得できます。params.id 経由でアクセスします。
  • クライアント側が推測するのではなく、明確に status: 404 を返します。

7. 特殊ファイル: loading、error、not-found

App Router は React Suspense と Error Boundary を規約ファイルを通じて暗黙的に使用するため、自分でバウンダリーを書く必要がありません。

// app/products/loading.tsx
export default function ProductsLoading() {
  return <p>商品一覧を読み込み中...</p>;
}
// app/products/error.tsx
"use client";

export default function ProductsError({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <p>エラー: {error.message}</p>
      <button onClick={reset}>再試行</button>
    </div>
  );
}
  • loading.tsx は、同じセグメントの page.tsx が await を実行している間、自動的に表示されます — useState(isLoading) は不要です。
  • error.tsx は必ず Client Component ("use client") である必要があります。エラーが発生したセグメントを再レンダリングするためのクライアント側の関数 reset を使用するためです。
  • Next.js はこれらのファイルに基づいて、<Suspense> とそれに対応するエラーバウンダリーでセグメントを自動的にラップします。

8. メタデータとネストされたレイアウト

各セグメントは独自の metadata をエクスポートでき、Next.js はそれらをルート layout からページまでの順に一つにまとめます。

// app/products/[id]/page.tsx
import type { Metadata } from "next";
import { ProductService } from "@/services/product-service";

type Props = { params: { id: string } };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const product = await ProductService.findById(params.id);
  return { title: product ? `${product.name} - Shop Nón Lá` : "見つかりません" };
}

export default async function ProductDetailPage({ params }: Props) {
  const product = await ProductService.findById(params.id);
  return <h1>{product?.name}</h1>;
}
  • generateMetadata は動的な関数で、実際の商品データに基づいて <title> を設定できます — 詳細ページの SEO に役立ちます。
  • ページ子の metadata は親 layout の同じ名前のフィールドをオーバーライドし、他のフィールドは保持されます。

9. Page コンポーネントのテスト

非同期 Server Component では、最も簡単なテスト方法はコンポーネント関数を直接呼び出して返される JSX をチェックするか、React Testing Library を使用してレンダリング後に検証することです。

<!-- package.json (devDependencies 部分、要約) -->
<dependencies>
  <vitest>^2.0.0</vitest>
  <testing-library-react>^16.0.0</testing-library-react>
</dependencies>
// services/product-service.test.ts
import { describe, it, expect, vi } from "vitest";
import { ProductService } from "@/services/product-service";
import ProductsPage from "@/app/products/page";
import { render, screen } from "@testing-library/react";

vi.mock("@/services/product-service", () => ({
  ProductService: {
    findAll: vi.fn(),
  },
});

describe("ProductsPage", () => {
  it("サービスから返された商品一覧を表示する", async () => {
    (ProductService.findAll as any).mockResolvedValue([
      { id: "1", name: "ノンラー A", price: 50000 },
    ]);

    const jsx = await ProductsPage();
    render(jsx);

    expect(screen.getByText("ノンラー A - 50000円")).toBeInTheDocument();
  });
});
  • ProductsPage() はプレーンな非同期関数として呼び出されます。本質的に JSX を返す関数です — Server Component 用の特別なテストフレームワークは必要ありません。
  • vi.mock は ProductService.findAll をスタブに置き換え、ダミーデータを返すので、テストは実際のデータベースに依存しません。
  • Testing Library の render(jsx) は、JSX が期待通りの内容で DOM にレンダリングされているかを検証します。

10. よくあるエラーと使用すべきでない場合

  • useState/useEffect などのフックを使用する際に "use client" を忘れる: Server Component はこれらのフックをサポートしていないため、Next.js は明確なビルドエラーを報告します。
  • API キーなどの機密ロジックを Client Component に配置する: コードはブラウザにバンドルされるためです。常にサーバーコンポーネントまたはルートハンドラーでのフェッチを優先します。
  • ルートグループを「整理」目的でのみ使用: 実際に異なるレイアウトが必要でない場合、ルートグループはフォルダツリーを複雑にするだけです。
  • 同じルートに対して pages/ と app/ を混在させる: 両方のルーターが同じ URL を処理することはできません。Next.js は app/ を優先し、pages/ 内のルートは無視されます — 段階的な移行時に混乱しやすいです。

注意: プロジェクトが Pages Router で正常に実行されており、Server Components、Streaming、Server Actions が必要ない場合、App Router への移行が必ずしも必要ではありません。変更前に利点を評価してください。

11. まとめ

App Router は次の 3 つの主要なファイルタイプを通じて Next.js アプリケーションを組織します: page.tsx がルートを定義し、layout.tsx が複数のルート間で UI を共有し、route.ts が HTML をレンダリングしない API を構築します。loading.tsx、error.tsx、not-found.tsx などの規約ファイルと組み合わせると、バウンダリーを自分で書くことなく、スムーズなローディング/エラーエクスペリエンスを構築できます。フォルダツリーを設計する際は、実際に共有する必要があるレイアウトでグループ化することを優先し、余分なルートグループを避け、常に Server/Client Component の境界を確認して、機密ロジックがブラウザに漏洩しないようにしてください。

Next.jsApp RouterReactServer ComponentsRouting