Next.js App Router入門|ディレクトリ構成・レイアウト・データ取得の実践

kento_morota 30分で読めます

Next.js App Routerは、Next.js 13で導入されたReact Server Componentsベースの新しいルーティングシステムです。従来のPages Routerと比べて、レイアウトのネスト、ストリーミング、Server Actionsなど、多くの新機能が利用可能になりました。

本記事では、App Routerの基本的なディレクトリ構成、レイアウトシステム、データ取得、Server Actions、そしてミドルウェアまで、実務で必要な知識を網羅的に解説します。

App Routerの基本概念

App Routerは、appディレクトリ内のフォルダ構造がそのままURLパスに対応するファイルベースルーティングを採用しています。

プロジェクトセットアップ

# 新規プロジェクト作成
npx create-next-app@latest my-app --typescript --tailwind --eslint --app --src-dir

# または pnpm
pnpm create next-app my-app --typescript --tailwind --eslint --app --src-dir

特殊ファイルの役割

App Routerでは、ファイル名に特別な意味があります。

app/
├── layout.tsx          # ルートレイアウト(必須)
├── page.tsx            # / のページ
├── loading.tsx         # ローディングUI(Suspense)
├── error.tsx           # エラーUI(Error Boundary)
├── not-found.tsx       # 404ページ
├── global-error.tsx    # グローバルエラーハンドラー
├── about/
│   └── page.tsx        # /about のページ
├── blog/
│   ├── page.tsx        # /blog のページ
│   ├── [slug]/
│   │   └── page.tsx    # /blog/:slug の動的ページ
│   └── layout.tsx      # /blog 配下共通レイアウト
├── api/
│   └── users/
│       └── route.ts    # /api/users のAPIエンドポイント
└── (marketing)/
    ├── pricing/
    │   └── page.tsx    # /pricing(グループ化、URLに影響しない)
    └── features/
        └── page.tsx    # /features

page.tsx:そのルートのメインコンテンツ。このファイルがあるディレクトリがURLとして公開されます。

layout.tsx:そのルート以下で共有されるレイアウト。ページ遷移時に再レンダリングされません。

loading.tsx:ページの読み込み中に表示されるUI。React Suspenseのフォールバックとして機能します。

error.tsx:エラー発生時に表示されるUI。React Error Boundaryとして機能します。

レイアウトシステム

App Routerのレイアウトは、ネスト可能な共有UIを定義する仕組みです。ページ遷移時にレイアウトの状態が保持され、不要な再レンダリングが発生しません。

ルートレイアウト

// src/app/layout.tsx(ルートレイアウト - 必須)
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: {
    default: "My App",
    template: "%s | My App", // 子ページのタイトルが入る
  },
  description: "My awesome application",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body className={inter.className}>
        <Header />
        <main>{children}</main>
        <Footer />
      </body>
    </html>
  );
}

ネストレイアウト

// src/app/dashboard/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getSession();

  // 未認証ユーザーはログインページへリダイレクト
  if (!session) {
    redirect("/login");
  }

  return (
    <div className="flex">
      <DashboardSidebar user={session.user} />
      <div className="flex-1 p-6">{children}</div>
    </div>
  );
}

ルートグループ

括弧()で囲んだフォルダ名は、URLに影響しないグルーピングに使えます。

app/
├── (auth)/
│   ├── layout.tsx        # 認証ページ共通レイアウト(シンプルなレイアウト)
│   ├── login/
│   │   └── page.tsx      # /login
│   └── register/
│       └── page.tsx      # /register
├── (dashboard)/
│   ├── layout.tsx        # ダッシュボード共通レイアウト(サイドバー付き)
│   ├── overview/
│   │   └── page.tsx      # /overview
│   └── settings/
│       └── page.tsx      # /settings
└── (marketing)/
    ├── layout.tsx        # マーケティングページ共通レイアウト
    ├── page.tsx          # /(トップページ)
    └── about/
        └── page.tsx      # /about

ページとルーティング

静的ページ

// src/app/about/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "会社概要",
  description: "当社の概要と沿革をご紹介します。",
};

export default function AboutPage() {
  return (
    <div>
      <h1>会社概要</h1>
      <p>ここに会社の説明が入ります。</p>
    </div>
  );
}

動的ルーティング

// src/app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import type { Metadata } from "next";

interface Props {
  params: Promise<{ slug: string }>;
}

// 動的メタデータ
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const article = await fetchArticle(slug);

  if (!article) return { title: "記事が見つかりません" };

  return {
    title: article.title,
    description: article.excerpt,
    openGraph: {
      title: article.title,
      description: article.excerpt,
      images: [article.thumbnail],
    },
  };
}

// 静的パスの生成(SSG)
export async function generateStaticParams() {
  const articles = await fetchAllArticleSlugs();
  return articles.map((article) => ({
    slug: article.slug,
  }));
}

export default async function ArticlePage({ params }: Props) {
  const { slug } = await params;
  const article = await fetchArticle(slug);

  if (!article) {
    notFound(); // not-found.tsx を表示
  }

  return (
    <article>
      <h1>{article.title}</h1>
      <time>{new Date(article.publishedAt).toLocaleDateString("ja-JP")}</time>
      <div dangerouslySetInnerHTML={{ __html: article.content }} />
    </article>
  );
}

キャッチオールルート

// src/app/docs/[...slug]/page.tsx
// /docs/getting-started, /docs/api/users, /docs/api/users/create など

interface Props {
  params: Promise<{ slug: string[] }>;
}

export default async function DocsPage({ params }: Props) {
  const { slug } = await params;
  // slug = ["getting-started"] or ["api", "users"] or ["api", "users", "create"]
  const path = slug.join("/");
  const doc = await fetchDoc(path);

  return (
    <div>
      <h1>{doc.title}</h1>
      <div>{doc.content}</div>
    </div>
  );
}

データ取得とキャッシュ

Server Componentでのデータ取得

// src/app/products/page.tsx
interface SearchParams {
  category?: string;
  page?: string;
  sort?: string;
}

export default async function ProductsPage({
  searchParams,
}: {
  searchParams: Promise<SearchParams>;
}) {
  const params = await searchParams;
  const page = parseInt(params.page || "1", 10);
  const category = params.category;
  const sort = params.sort || "newest";

  // サーバーサイドでデータ取得
  const { products, total, totalPages } = await fetchProducts({
    page,
    category,
    sort,
    perPage: 20,
  });

  return (
    <div>
      <h1>商品一覧</h1>
      <p>全{total}件</p>

      <ProductFilters currentCategory={category} currentSort={sort} />

      <div className="grid grid-cols-3 gap-4">
        {products.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>

      <Pagination currentPage={page} totalPages={totalPages} />
    </div>
  );
}

loading.tsxとerror.tsx

// src/app/products/loading.tsx
export default function ProductsLoading() {
  return (
    <div>
      <h1>商品一覧</h1>
      <div className="grid grid-cols-3 gap-4">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="animate-pulse bg-gray-200 h-64 rounded" />
        ))}
      </div>
    </div>
  );
}
// src/app/products/error.tsx
"use client"; // Error Boundaryはクライアントコンポーネントである必要がある

export default function ProductsError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <h2>エラーが発生しました</h2>
      <p>{error.message}</p>
      <button onClick={reset}>再試行</button>
    </div>
  );
}

キャッシュ戦略の使い分け

// 1. 静的生成(デフォルト)
// ビルド時にHTMLを生成、CDNにキャッシュ
async function StaticPage() {
  const data = await fetch("https://api.example.com/data");
  return <div>{/* ... */}</div>;
}

// 2. ISR(Incremental Static Regeneration)
// 指定時間ごとにバックグラウンドで再生成
async function ISRPage() {
  const data = await fetch("https://api.example.com/data", {
    next: { revalidate: 3600 }, // 1時間ごと
  });
  return <div>{/* ... */}</div>;
}

// 3. 動的レンダリング
// リクエストごとにサーバーで実行
import { cookies, headers } from "next/headers";

async function DynamicPage() {
  const cookieStore = await cookies(); // cookies()を使うと自動的に動的になる
  const token = cookieStore.get("auth-token");
  return <div>{/* ... */}</div>;
}

// セグメント単位での設定
export const dynamic = "force-dynamic"; // 常に動的
// export const dynamic = "force-static"; // 常に静的
// export const revalidate = 3600; // ISR(1時間)

Server Actions

Server Actionsは、フォーム送信やデータ変更をサーバー側で処理するための仕組みです。APIエンドポイントを別途作成する必要がありません。

フォーム処理の実装

// src/app/articles/new/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";

const ArticleSchema = z.object({
  title: z.string().min(1, "タイトルは必須です").max(200),
  content: z.string().min(1, "本文は必須です"),
  category: z.string().min(1, "カテゴリを選択してください"),
  tags: z.string().transform((val) => val.split(",").map((t) => t.trim()).filter(Boolean)),
  status: z.enum(["draft", "published"]),
});

export type ArticleFormState = {
  success: boolean;
  errors: Record<string, string[]>;
  message?: string;
};

export async function createArticle(
  prevState: ArticleFormState,
  formData: FormData
): Promise<ArticleFormState> {
  const rawData = {
    title: formData.get("title"),
    content: formData.get("content"),
    category: formData.get("category"),
    tags: formData.get("tags"),
    status: formData.get("status"),
  };

  const result = ArticleSchema.safeParse(rawData);

  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors as Record<string, string[]>,
    };
  }

  try {
    const article = await db.article.create({
      data: {
        ...result.data,
        authorId: await getCurrentUserId(),
      },
    });

    revalidatePath("/articles");
    redirect(`/articles/${article.slug}`);
  } catch (error) {
    return {
      success: false,
      errors: {},
      message: "記事の作成に失敗しました",
    };
  }
}
// src/app/articles/new/page.tsx
import { ArticleForm } from "./ArticleForm";

export default function NewArticlePage() {
  return (
    <div>
      <h1>記事作成</h1>
      <ArticleForm />
    </div>
  );
}
// src/app/articles/new/ArticleForm.tsx
"use client";

import { useActionState } from "react";
import { createArticle, type ArticleFormState } from "./actions";

const initialState: ArticleFormState = {
  success: false,
  errors: {},
};

export function ArticleForm() {
  const [state, formAction, isPending] = useActionState(createArticle, initialState);

  return (
    <form action={formAction}>
      {state.message && <div className="error">{state.message}</div>}

      <div>
        <label htmlFor="title">タイトル</label>
        <input id="title" name="title" type="text" required />
        {state.errors.title?.map((e) => <span key={e} className="error">{e}</span>)}
      </div>

      <div>
        <label htmlFor="content">本文</label>
        <textarea id="content" name="content" rows={10} required />
        {state.errors.content?.map((e) => <span key={e} className="error">{e}</span>)}
      </div>

      <div>
        <label htmlFor="category">カテゴリ</label>
        <select id="category" name="category">
          <option value="">選択してください</option>
          <option value="tech">技術</option>
          <option value="business">ビジネス</option>
        </select>
      </div>

      <div>
        <label htmlFor="tags">タグ(カンマ区切り)</label>
        <input id="tags" name="tags" type="text" placeholder="React, Next.js, TypeScript" />
      </div>

      <div>
        <label>
          <input type="radio" name="status" value="draft" defaultChecked /> 下書き
        </label>
        <label>
          <input type="radio" name="status" value="published" /> 公開
        </label>
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? "作成中..." : "記事を作成"}
      </button>
    </form>
  );
}

API RoutesとMiddleware

Route Handlers(API Routes)

// src/app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const page = parseInt(searchParams.get("page") || "1", 10);
  const perPage = parseInt(searchParams.get("perPage") || "20", 10);

  const users = await db.user.findMany({
    skip: (page - 1) * perPage,
    take: perPage,
  });
  const total = await db.user.count();

  return NextResponse.json({
    data: users,
    pagination: { page, perPage, total, totalPages: Math.ceil(total / perPage) },
  });
}

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const user = await db.user.create({ data: body });
    return NextResponse.json({ data: user }, { status: 201 });
  } catch (error) {
    return NextResponse.json(
      { error: "ユーザーの作成に失敗しました" },
      { status: 500 }
    );
  }
}
// src/app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const user = await db.user.findUnique({ where: { id } });

  if (!user) {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }

  return NextResponse.json({ data: user });
}

export async function PUT(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const body = await request.json();
  const user = await db.user.update({ where: { id }, data: body });
  return NextResponse.json({ data: user });
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  await db.user.delete({ where: { id } });
  return new NextResponse(null, { status: 204 });
}

Middleware

// src/middleware.ts(プロジェクトルートに配置)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // 認証チェック
  const token = request.cookies.get("auth-token")?.value;

  // ダッシュボード配下は認証必須
  if (pathname.startsWith("/dashboard") && !token) {
    const loginUrl = new URL("/login", request.url);
    loginUrl.searchParams.set("redirect", pathname);
    return NextResponse.redirect(loginUrl);
  }

  // 認証済みユーザーがログインページにアクセスしたらダッシュボードへ
  if ((pathname === "/login" || pathname === "/register") && token) {
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }

  // レスポンスヘッダーの追加
  const response = NextResponse.next();
  response.headers.set("X-Request-Id", crypto.randomUUID());

  return response;
}

// Middlewareを適用するパスの設定
export const config = {
  matcher: [
    // 静的ファイル・API以外に適用
    "/((?!_next/static|_next/image|favicon.ico|api).*)",
  ],
};

実務でのディレクトリ構成ベストプラクティス

src/
├── app/                    # App Router
│   ├── (auth)/             # 認証関連ページ
│   │   ├── layout.tsx
│   │   ├── login/
│   │   └── register/
│   ├── (dashboard)/        # ダッシュボード
│   │   ├── layout.tsx
│   │   ├── overview/
│   │   └── settings/
│   ├── (marketing)/        # 公開ページ
│   │   ├── layout.tsx
│   │   ├── page.tsx        # トップページ
│   │   ├── about/
│   │   └── blog/
│   ├── api/                # API Routes
│   ├── layout.tsx          # ルートレイアウト
│   └── not-found.tsx       # 404
├── components/             # 共有コンポーネント
│   ├── ui/                 # UIプリミティブ(Button, Input等)
│   ├── layout/             # Header, Footer, Sidebar等
│   └── features/           # 機能単位のコンポーネント
├── lib/                    # ユーティリティ・設定
│   ├── db.ts               # DB接続
│   ├── auth.ts             # 認証ヘルパー
│   └── utils.ts            # 汎用ユーティリティ
├── types/                  # 型定義
└── middleware.ts            # Middleware

ポイントは、appディレクトリにはルーティングに関するファイルだけを配置し、再利用可能なコンポーネントやユーティリティはcomponentslibに分離することです。

まとめ

Next.js App Routerは、React Server Componentsを基盤とした新しいWeb開発のパラダイムです。

ファイルベースルーティング:appディレクトリのフォルダ構造がURLに対応します。page.tsx、layout.tsx、loading.tsx、error.tsxなどの特殊ファイルが自動的に適切な役割を果たします。

レイアウト:ネスト可能なレイアウトにより、共有UIの定義が容易です。ルートグループ()でURLに影響しないグルーピングも可能です。

データ取得:Server Componentsでasync/awaitによる直接的なデータ取得が可能です。キャッシュ戦略(静的生成、ISR、動的レンダリング)をfetchオプションやセグメント設定で制御します。

Server Actions:フォーム処理やデータ変更をサーバー側で型安全に処理できます。APIエンドポイントの作成が不要になるケースが多くなります。

Middleware:認証チェック、リダイレクト、ヘッダー操作などをEdge Runtimeで高速に処理します。

App Routerは学ぶべき概念が多いですが、Server Components、レイアウト、Server Actionsの3つを押さえれば実務で十分に活用できます。まずは小さなプロジェクトで試し、段階的に高度な機能を取り入れていきましょう。

#Next.js#App Router#React
共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

起業準備に役立つ情報、もっとありますよ。

まずは話だけ聞いてもらう