Next.js 13 で安定版が登場し、Next.js 14 / 15 でデファクトとなった App Router は、もはや「新機能」ではなく「Next.js を選ぶ理由そのもの」と言ってよい中核アーキテクチャです。app/ ディレクトリ配下にファイルを置くだけで page / layout / loading / error / not-found / template / default / route / middleware といったファイル規約が即座にルーティング・データフェッチ・エラー境界・Streaming SSR の役割を担い、Pages Router 時代の getServerSideProps / getStaticProps / _app.tsx / _document.tsx の煩雑さは一気に消滅しました。
本記事は Next.js 15.x / React 19 / TypeScript 5.6 / Node.js 22 LTS 準拠で、コピペで動く 40 以上のコードサンプルを通して、Pages → App Router 移行ガイド → page.tsx / layout.tsx / loading.tsx / error.tsx / not-found.tsx / template.tsx / default.tsx の 7 大ファイル規約 → ネストレイアウト / Route Group (group) / Private Folder _folder / Dynamic [id] / Catch-all [...slug] / Optional [[...slug]] → Parallel Route @slot と Intercepting Route (.) → route.ts ルートハンドラ → middleware.ts → generateMetadata / sitemap.ts / robots.ts による SEO 最適化 → revalidatePath / revalidateTag / redirect / notFound() の制御フロー → useSelectedLayoutSegment / useRouter / usePathname までを、App Router ファイル規約・ルーティング観点に完全特化して解説します。
本記事は「App Router のファイル規約・ルーティング観点」に絞っているため、React Server Components 完全ガイド(RSC / Server Actions 観点)、React Suspense 完全ガイド(<Suspense> / use / Streaming 観点)、モダンビルドツール完全ガイド(Turbopack 観点)、React パフォーマンス最適化完全ガイド(レンダリング観点)と相互補完的に読むと、App Router の「設計思想」と「実装テクニック」が一気通貫で身につきます。
- 1. App Router の全体像と Pages Router との違い
- 2. ファイル規約 7 種を完全理解する
- 3. メタデータ API と SEO 最適化
- 4. ルーティングセグメントの 6 種類を使い分ける
- 5. Route Handler ―― API Routes の後継
- 6. middleware.ts ―― 認証・i18n・A/B テスト
- 7. データ取得とキャッシュ戦略
- 8. ナビゲーション API と制御フロー
- 9. Server Component と Client Component の境界
- 10. よくある落とし穴と現場で効くベストプラクティス
- 11. 学習ロードマップとプロが選ぶ次の一歩
- 12. まとめ
1. App Router の全体像と Pages Router との違い
App Router の本質は「ファイル名・フォルダ名がそのまま意味を持つ規約ベースのルーティング」と「デフォルト Server Component」の 2 点に集約されます。Pages Router が「ファイルが 1 URL に対応するだけのシンプルさ」だったのに対し、App Router は「フォルダがネスト UI 単位として振る舞う」設計に進化しました。
1.1 ディレクトリ構造の比較
// ❌ Pages Router(旧来)
pages/
├── _app.tsx // 全ページ共通ラッパー
├── _document.tsx // HTML スケルトン
├── index.tsx // /
├── about.tsx // /about
├── blog/
│ ├── index.tsx // /blog
│ └── [slug].tsx // /blog/:slug
└── api/
└── hello.ts // /api/hello
// ✅ App Router(Next.js 13+ / 15 推奨)
app/
├── layout.tsx // Root Layout(_app + _document 統合)
├── page.tsx // /
├── loading.tsx // Streaming スケルトン
├── error.tsx // Error Boundary
├── not-found.tsx // 404
├── about/
│ └── page.tsx // /about
├── blog/
│ ├── page.tsx // /blog
│ └── [slug]/
│ └── page.tsx // /blog/:slug
└── api/
└── hello/
└── route.ts // /api/hello(Route Handler)
1.2 主な API マッピング(Pages → App)
// Pages Router → App Router
// ─────────────────────────────────────────────────────
// getServerSideProps → 非同期 Server Component 本体 + fetch({ cache: "no-store" })
// getStaticProps → 非同期 Server Component + fetch({ next: { revalidate: 60 } })
// getStaticPaths → generateStaticParams()
// getInitialProps → 非推奨(削除推奨)
// _app.tsx → app/layout.tsx
// _document.tsx → app/layout.tsx の <html><body> 直書き
// next/head <Head> → export const metadata / generateMetadata()
// pages/api/* → app/.../route.ts(GET / POST / PUT / DELETE 関数 export)
// useRouter().query → useParams() + useSearchParams()
// router.push → useRouter().push(import元が next/navigation に変更)
1.3 移行戦略 ―― 段階的に共存させる
Next.js は pages/ と app/ の共存を公式にサポートしています。同一 URL は app/ が優先されるため、リスクの低いページから 1 本ずつ app/ へ移植するのが定石です。
// next.config.ts(Next.js 15 推奨 .ts 設定ファイル)
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// App Router は v13.4 以降デフォルト有効。明示する場合のみ:
experimental: {
// typedRoutes は型安全な Link を提供(任意)
typedRoutes: true,
},
// Pages Router と共存中は段階的に App へリダイレクトしていく
async redirects() {
return [
{ source: "/legacy-about", destination: "/about", permanent: true },
];
},
};
export default nextConfig;
2. ファイル規約 7 種を完全理解する
App Router の核心は、特定のファイル名がそのまま UI コンポーネントの役割を決定する規約システムです。覚えるべきは 7 種類だけです。
2.1 page.tsx ―― そのセグメントの実体 UI
// app/blog/page.tsx
// このファイルがあるフォルダのパスが URL になる(/blog)
// デフォルトで Server Component。fetch を await できる。
export default async function BlogPage() {
const posts = await fetch("https://api.example.com/posts", {
next: { revalidate: 300 }, // 5 分 ISR
}).then((r) => r.json() as Promise<{ id: string; title: string }[]>);
return (
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
2.2 layout.tsx ―― ネストする永続レイアウト
// app/layout.tsx(Root Layout: 必ず <html><body> を含む)
import type { ReactNode } from "react";
import "./globals.css";
export const metadata = {
title: { default: "MyApp", template: "%s | MyApp" },
description: "Next.js 15 + React 19 demo",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ja">
<body>
<header>共通ヘッダー</header>
<main>{children}</main>
<footer>共通フッター</footer>
</body>
</html>
);
}
// app/blog/layout.tsx(Nested Layout: ブログ全体に共通の枠)
// Root Layout の中に入れ子で描画される。再マウントされず状態を保つ。
import type { ReactNode } from "react";
import Link from "next/link";
export default function BlogLayout({ children }: { children: ReactNode }) {
return (
<section className="blog">
<nav>
<Link href="/blog">一覧</Link> /{" "}
<Link href="/blog/categories">カテゴリ</Link>
</nav>
{children}
</section>
);
}
2.3 loading.tsx ―― Streaming スケルトン
// app/blog/loading.tsx
// React Suspense の境界として自動配線される。
// page.tsx が await している間に表示される即時 UI。
export default function Loading() {
return (
<div className="skeleton" aria-busy="true">
<div className="skeleton__row" />
<div className="skeleton__row" />
<div className="skeleton__row" />
</div>
);
}
2.4 error.tsx ―― Error Boundary
// app/blog/error.tsx
"use client"; // error.tsx は必ず Client Component
import { useEffect } from "react";
export default function BlogError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Sentry / DataDog などへ送る
console.error(error);
}, [error]);
return (
<div role="alert">
<h2>ブログの読み込みに失敗しました</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>再試行</button>
</div>
);
}
2.5 global-error.tsx ―― Root の最終境界
// app/global-error.tsx
// Root Layout 自体がクラッシュしたときの最終フォールバック。
// この中で <html><body> を自分で書く必要がある(Root Layout が機能していないため)。
"use client";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html lang="ja">
<body>
<h1>申し訳ありません、致命的なエラーが発生しました</h1>
<p>{error.digest}</p>
<button onClick={reset}>リロード</button>
</body>
</html>
);
}
2.6 not-found.tsx ―― 404 を任意セグメントで定義
// app/blog/[slug]/not-found.tsx
// notFound() が呼ばれたとき、最も近い not-found.tsx が表示される。
import Link from "next/link";
export default function PostNotFound() {
return (
<div>
<h2>記事が見つかりません</h2>
<Link href="/blog">ブログ一覧に戻る</Link>
</div>
);
}
2.7 template.tsx ―― 再マウント版 Layout
// app/onboarding/template.tsx
// layout.tsx と違い、ナビゲーション毎に「再マウント」される。
// アニメーション初期化や useEffect の再実行が必要なときに使う。
import type { ReactNode } from "react";
export default function OnboardingTemplate({ children }: { children: ReactNode }) {
return <div className="fade-in">{children}</div>;
}
2.8 default.tsx ―― Parallel Route のフォールバック
// app/@modal/default.tsx
// Parallel Route で「描画すべきセグメントが存在しないとき」のフォールバック。
// これがないとフルリロード時に 404 が出るので、@slot ごとに必ず置く。
export default function Default() {
return null; // モーダル無しの状態
}
3. メタデータ API と SEO 最適化
App Router では next/head や <Head> コンポーネントは廃止され、静的 metadata エクスポートまたは動的 generateMetadata 関数で title / description / OG / Twitter / canonical / robots を一元管理します。
3.1 静的メタデータ
// app/about/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "会社概要",
description: "MyApp の運営会社情報",
alternates: {
canonical: "https://example.com/about",
languages: { ja: "/about", en: "/en/about" },
},
openGraph: {
title: "会社概要 | MyApp",
description: "MyApp の運営会社情報",
url: "https://example.com/about",
siteName: "MyApp",
images: [{ url: "/og/about.png", width: 1200, height: 630 }],
locale: "ja_JP",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "会社概要 | MyApp",
images: ["/og/about.png"],
},
robots: { index: true, follow: true },
};
export default function AboutPage() {
return <h1>会社概要</h1>;
}
3.2 動的メタデータ(記事詳細など)
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await fetch(`https://api.example.com/posts/${slug}`).then((r) =>
r.ok ? r.json() : null
);
if (!post) return { title: "Not Found" };
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
images: [{ url: post.cover }],
type: "article",
publishedTime: post.publishedAt,
},
};
}
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await fetch(`https://api.example.com/posts/${slug}`).then((r) =>
r.ok ? r.json() : null
);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.body }} />
</article>
);
}
Next.js 15 の重要破壊的変更: params と searchParams は Promise になりました。必ず await で取り出してください(Next.js 14 までは同期オブジェクトでした)。
3.3 sitemap.ts と robots.ts(ファイルベース生成)
// app/sitemap.ts
import type { MetadataRoute } from "next";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await fetch("https://api.example.com/posts").then((r) => r.json());
const staticUrls: MetadataRoute.Sitemap = [
{ url: "https://example.com/", lastModified: new Date(), priority: 1 },
{ url: "https://example.com/about", lastModified: new Date(), priority: 0.5 },
];
const postUrls: MetadataRoute.Sitemap = posts.map((p: { slug: string; updatedAt: string }) => ({
url: `https://example.com/blog/${p.slug}`,
lastModified: new Date(p.updatedAt),
priority: 0.7,
}));
return [...staticUrls, ...postUrls];
}
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: "*", allow: "/", disallow: ["/admin", "/api/private"] },
],
sitemap: "https://example.com/sitemap.xml",
host: "https://example.com",
};
}
3.4 OG 画像の動的生成(opengraph-image.tsx)
// app/blog/[slug]/opengraph-image.tsx
// Next.js が自動で /opengraph-image を生成し、og:image に紐付ける。
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image({ params }: { params: { slug: string } }) {
const post = await fetch(`https://api.example.com/posts/${params.slug}`).then((r) => r.json());
return new ImageResponse(
(
<div style={{ display: "flex", width: "100%", height: "100%", background: "#111", color: "#fff", padding: 80, fontSize: 64 }}>
{post.title}
</div>
),
size
);
}
4. ルーティングセグメントの 6 種類を使い分ける
App Router のフォルダ命名規約は 6 種類の特殊構文を持っています。これを理解すれば、複雑なルーティングを 1 ファイルも書かずに URL 設計だけで表現できます。
4.1 Route Group (folder) ―― URL に出ないグルーピング
// 括弧で囲んだフォルダは URL に現れない。
// 同じ階層の URL で、レイアウトだけを切り分けたいときに使う。
app/
├── (marketing)/
│ ├── layout.tsx // マーケ用ヘッダー
│ ├── page.tsx // /
│ └── pricing/
│ └── page.tsx // /pricing
└── (app)/
├── layout.tsx // ログイン後ヘッダー
└── dashboard/
└── page.tsx // /dashboard
4.2 Private Folder _folder ―― ルーティングから除外
// アンダースコア始まりのフォルダはルーティング対象外。
// 共通コンポーネントや util を app/ 配下に置きたいときに使う。
app/
├── _components/
│ ├── Header.tsx // ❌ URL にならない
│ └── Footer.tsx
├── _lib/
│ └── auth.ts // ❌ URL にならない
└── dashboard/
└── page.tsx
4.3 Dynamic Route [id]
// app/users/[id]/page.tsx
type Props = { params: Promise<{ id: string }> };
export default async function UserPage({ params }: Props) {
const { id } = await params;
const user = await fetch(`https://api.example.com/users/${id}`).then((r) => r.json());
return <h1>{user.name}</h1>;
}
// SSG したいなら generateStaticParams で URL を列挙
export async function generateStaticParams() {
const users = await fetch("https://api.example.com/users").then((r) => r.json());
return users.map((u: { id: string }) => ({ id: u.id }));
}
4.4 Catch-all […slug]
// app/docs/[...slug]/page.tsx
// /docs/getting-started → slug = ["getting-started"]
// /docs/api/auth/jwt → slug = ["api", "auth", "jwt"]
type Props = { params: Promise<{ slug: string[] }> };
export default async function DocsPage({ params }: Props) {
const { slug } = await params;
return <pre>{JSON.stringify(slug, null, 2)}</pre>;
}
4.5 Optional Catch-all [[…slug]]
// app/shop/[[...slug]]/page.tsx
// /shop → slug = undefined
// /shop/men → slug = ["men"]
// /shop/men/hat → slug = ["men", "hat"]
type Props = { params: Promise<{ slug?: string[] }> };
export default async function Shop({ params }: Props) {
const { slug } = await params;
if (!slug) return <h1>Shop Top</h1>;
return <h1>/{slug.join("/")}</h1>;
}
4.6 Parallel Route @slot
// app/dashboard/layout.tsx は children だけでなく @slot を受け取れる
import type { ReactNode } from "react";
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: ReactNode;
analytics: ReactNode;
team: ReactNode;
}) {
return (
<div className="grid">
<section>{children}</section>
<aside>{analytics}</aside>
<aside>{team}</aside>
</div>
);
}
// app/dashboard/@analytics/page.tsx → analytics slot に流れる
// app/dashboard/@team/page.tsx → team slot に流れる
// app/dashboard/@analytics/default.tsx と @team/default.tsx も必須
4.7 Intercepting Route (.) (..) (…)
// 「同じ URL でも遷移の文脈次第で別 UI を出す」ためのモーダル UX 等で使う。
// 例: 一覧から写真をクリックしたらモーダル、URL 直叩きならフルページ。
app/
├── feed/
│ └── page.tsx // /feed(一覧)
├── photo/
│ └── [id]/
│ └── page.tsx // /photo/123(フルページ)
└── @modal/
└── (.)photo/
└── [id]/
└── page.tsx // /feed から /photo/123 → モーダルで開く
// (.) = 同じ階層を intercept
// (..) = 1 つ上の階層を intercept
// (...) = ルートを intercept
5. Route Handler ―― API Routes の後継
Pages Router の pages/api/*.ts は、App Router では app/.../route.ts に置き換わりました。HTTP メソッド名を named export するだけで、Web 標準の Request / Response を直接扱えます。
5.1 基本の GET / POST
// app/api/posts/route.ts
import { NextResponse } from "next/server";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const limit = Number(searchParams.get("limit") ?? 10);
const posts = await db.post.findMany({ take: limit });
return NextResponse.json(posts, {
headers: { "Cache-Control": "s-maxage=60, stale-while-revalidate" },
});
}
export async function POST(req: Request) {
const body = await req.json();
const created = await db.post.create({ data: body });
return NextResponse.json(created, { status: 201 });
}
5.2 動的セグメント + バリデーション
// app/api/posts/[id]/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
const PatchSchema = z.object({ title: z.string().min(1).max(120) });
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const parsed = PatchSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const updated = await db.post.update({ where: { id }, data: parsed.data });
return NextResponse.json(updated);
}
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await db.post.delete({ where: { id } });
return new Response(null, { status: 204 });
}
5.3 ランタイム指定とキャッシュ制御
// app/api/edge/route.ts
export const runtime = "edge"; // Vercel Edge / Cloudflare Workers
export const dynamic = "force-dynamic"; // 毎回実行(キャッシュさせない)
export const revalidate = 0;
export async function GET() {
return Response.json({ ts: Date.now() });
}
6. middleware.ts ―― 認証・i18n・A/B テスト
middleware.ts はルーティング前に走る Edge Functionで、認証ガード・リダイレクト・地域別言語切替・A/B テストの実装ポイントとして欠かせません。
6.1 認証ガードの最小実装
// middleware.ts(プロジェクトルート、app/ と同階層)
import { NextResponse, type NextRequest } from "next/server";
export function middleware(req: NextRequest) {
const token = req.cookies.get("session")?.value;
const isProtected = req.nextUrl.pathname.startsWith("/dashboard");
if (isProtected && !token) {
const url = req.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("from", req.nextUrl.pathname);
return NextResponse.redirect(url);
}
return NextResponse.next();
}
// 適用するパスを matcher で絞る(全 URL に走らせると遅い)
export const config = {
matcher: ["/dashboard/:path*", "/account/:path*"],
};
6.2 i18n リダイレクト
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
const LOCALES = ["ja", "en", "zh"] as const;
const DEFAULT = "ja";
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const has = LOCALES.some((l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`);
if (has) return NextResponse.next();
const accept = req.headers.get("accept-language") ?? "";
const lang = LOCALES.find((l) => accept.includes(l)) ?? DEFAULT;
const url = req.nextUrl.clone();
url.pathname = `/${lang}${pathname}`;
return NextResponse.redirect(url);
}
export const config = { matcher: ["/((?!_next|api|favicon.ico).*)"] };
7. データ取得とキャッシュ戦略
App Router は Server Component の中で 素の fetch を await するだけでデータ取得が完結します。Next.js は fetch を拡張し、cache オプションと next.revalidate でキャッシュ戦略を宣言できます。
7.1 SSR / ISR / SSG を 1 行で切り替える
// SSR(毎リクエスト fetch、キャッシュなし) ―― 旧 getServerSideProps 相当
const res = await fetch(url, { cache: "no-store" });
// SSG(ビルド時に固定、変わらない) ―― 旧 getStaticProps 相当
const res = await fetch(url, { cache: "force-cache" });
// ISR(60 秒ごとに再生成) ―― 旧 getStaticProps + revalidate
const res = await fetch(url, { next: { revalidate: 60 } });
// タグ付きキャッシュ ―― revalidateTag("posts") で明示的に破棄
const res = await fetch(url, { next: { tags: ["posts"], revalidate: 3600 } });
7.2 revalidatePath / revalidateTag(Server Action から呼ぶ)
// app/actions/posts.ts(Server Action)
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function createPost(formData: FormData) {
await db.post.create({ data: { title: String(formData.get("title")) } });
// パス単位でキャッシュ破棄(該当ページの ISR キャッシュをクリア)
revalidatePath("/blog");
// タグ単位でキャッシュ破棄(複数ページ横断)
revalidateTag("posts");
}
7.3 セグメント単位のキャッシュ設定
// app/blog/page.tsx の最上位で宣言できる Route Segment Config
export const dynamic = "force-dynamic"; // または "force-static" | "auto"
export const revalidate = 60; // 秒。0 で常時動的。
export const fetchCache = "default-cache"; // "force-no-store" 等
export const runtime = "nodejs"; // または "edge"
export const preferredRegion = "iad1"; // デプロイリージョン
7.4 React の cache() で重複排除
// app/_lib/posts.ts
import { cache } from "react";
// 同一レンダリング内で同じ引数のときは結果を使い回す。
// layout.tsx と page.tsx の両方で getPost(slug) を呼んでも 1 回しか fetch されない。
export const getPost = cache(async (slug: string) => {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { tags: [`post:${slug}`], revalidate: 300 },
});
if (!res.ok) return null;
return (await res.json()) as { title: string; body: string };
});
8. ナビゲーション API と制御フロー
App Router のナビゲーション系 API は next/navigation に集約されました。Pages Router の next/router は使えないので注意してください。
8.1 redirect / permanentRedirect / notFound
// Server Component / Server Action 内で呼べる制御フロー
import { redirect, permanentRedirect, notFound } from "next/navigation";
export default async function Page() {
const user = await getCurrentUser();
if (!user) redirect("/login"); // 307 一時リダイレクト
if (user.deletedAt) permanentRedirect("/gone"); // 308 永続リダイレクト
const post = await getPost("hello");
if (!post) notFound(); // 最寄りの not-found.tsx を表示
return <article>{post.title}</article>;
}
8.2 useRouter / usePathname / useSearchParams
// app/_components/SearchBox.tsx
"use client";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
export function SearchBox() {
const router = useRouter();
const pathname = usePathname();
const params = useSearchParams();
const q = params.get("q") ?? "";
function setQuery(next: string) {
const sp = new URLSearchParams(params.toString());
if (next) sp.set("q", next);
else sp.delete("q");
router.replace(`${pathname}?${sp.toString()}`, { scroll: false });
}
return (
<input value={q} onChange={(e) => setQuery(e.currentTarget.value)} placeholder="検索..." />
);
}
8.3 useSelectedLayoutSegment(s)
// app/_components/Tabs.tsx
"use client";
import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
export function DashboardTabs() {
const seg = useSelectedLayoutSegment(); // "settings" | "analytics" | null
return (
<nav>
<Link href="/dashboard" aria-current={seg === null ? "page" : undefined}>概要</Link>
<Link href="/dashboard/settings" aria-current={seg === "settings" ? "page" : undefined}>設定</Link>
<Link href="/dashboard/analytics" aria-current={seg === "analytics" ? "page" : undefined}>分析</Link>
</nav>
);
}
8.4 Link prefetch と scroll 制御
import Link from "next/link";
// デフォルトで viewport 内に入ると自動 prefetch される(本番のみ)
<Link href="/blog/hello">記事を読む</Link>
// prefetch を切る(プライベートな大型ページ等)
<Link href="/admin/users" prefetch={false}>管理画面</Link>
// アンカー遷移後にスクロールを抑制(同一ページのタブ切替などに便利)
<Link href="/dashboard/settings" scroll={false}>設定</Link>
// 置換ナビゲーション(履歴を残さない)
<Link href="/step-2" replace>次へ</Link>
9. Server Component と Client Component の境界
App Router のもう 1 つの本質は「すべてのファイルがデフォルト Server Component」であることです。useState や useEffect やブラウザ API を使うときだけ "use client" ディレクティブを付けて Client Component に切り替えます。
9.1 “use client” のスコープルール
// "use client" を書いたファイル「以下のツリー」が Client Component 扱いになる。
// Server Component から Client Component を import するのは OK。
// Client Component から Server Component を import すると Server Component は Client 化される(避ける)。
// ✅ 良いパターン: Server がレイアウト、Client は葉
// app/page.tsx (Server)
import { Counter } from "./_components/Counter"; // Client
export default async function Home() {
const list = await fetchList();
return (
<>
<ul>{list.map((x) => <li key={x.id}>{x.name}</li>)}</ul>
<Counter />
</>
);
}
// app/_components/Counter.tsx (Client)
"use client";
import { useState } from "react";
export function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}
9.2 Server Component を Client に渡す children パターン
// Client Wrapper が children として Server Component を受け取れる。
// 「クライアントの境界を保ちつつ Server で描いた DOM を入れ子で配置」できる。
// app/_components/Modal.tsx (Client)
"use client";
import { useState, type ReactNode } from "react";
export function Modal({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>開く</button>
{open && <div className="modal">{children}</div>}
</>
);
}
// app/page.tsx (Server)
import { Modal } from "./_components/Modal";
export default async function Home() {
const post = await getPost("hello"); // Server で取得
return (
<Modal>
<article>{post.title}</article>
</Modal>
);
}
9.3 Server Action(form action として渡す)
// app/contact/page.tsx
import { redirect } from "next/navigation";
async function submitContact(formData: FormData) {
"use server";
const name = String(formData.get("name"));
const message = String(formData.get("message"));
await db.contact.create({ data: { name, message } });
redirect("/contact/thanks");
}
export default function ContactPage() {
return (
<form action={submitContact}>
<input name="name" required />
<textarea name="message" required />
<button type="submit">送信</button>
</form>
);
}
10. よくある落とし穴と現場で効くベストプラクティス
10.1 params / searchParams を await し忘れる
// ❌ Next.js 15 で警告 + 動作不能
export default function Page({ params }: { params: { id: string } }) {
return <p>{params.id}</p>;
}
// ✅ Promise を await する(15 以降の正式 API)
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <p>{id}</p>;
}
10.2 Client Component で fetch のキャッシュタグが効かない
// ❌ Client Component の fetch には next.tags は通らない(無視される)
"use client";
useEffect(() => {
fetch("/api/posts", { next: { tags: ["posts"] } }); // ← 無効
}, []);
// ✅ キャッシュ制御は Server Component / Route Handler 側で行い、
// Client では SWR / TanStack Query を使うのが王道
10.3 Root Layout で動的タグを使ってしまう
// ❌ Root Layout 内で cookies() / headers() を呼ぶと全画面が動的化する
import { cookies } from "next/headers";
export default async function RootLayout({ children }) {
const c = await cookies();
// ↑ ここで参照すると、配下の全ページが SSG 不可になる
return <html><body>{children}</body></html>;
}
// ✅ 動的な部分は Client Boundary に分離するか、対象セグメントの page で呼ぶ
10.4 not-found.tsx と 404 の関係を勘違いする
// notFound() を呼ぶと「最も近い not-found.tsx」が表示される。
// app/blog/[slug]/not-found.tsx があれば、それが優先される。
// なければ app/not-found.tsx(全体 404)が表示される。
// ✅ 業務的に重要な分岐は、ローカル not-found.tsx を必ず置く
10.5 middleware が重い ―― matcher で絞り込む
// ❌ matcher なし → 静的アセットや _next/* にまで middleware が走り、Cold Start を浪費
export function middleware(req) { /* ... */ }
// ✅ 必要なパスだけに絞る
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|images|fonts).*)",
],
};
10.6 Parallel Route の default.tsx を忘れる
// @modal などの slot は、URL 直叩きで「描画すべき子」が存在しない場合に 404 になる。
// 必ず default.tsx を置いて空ノードでフォールバックさせる。
// app/@modal/default.tsx
export default function Default() { return null; }
10.7 use client を「とりあえず付ける」のをやめる
// "use client" を付けるたびに JS バンドルが増え、Hydration コストが増える。
// 必要なのは「状態 / イベント / ブラウザ API を使うコンポーネントだけ」。
// 静的に描けるものは Server のままにし、Counter / Form 等の葉だけ Client にする。
11. 学習ロードマップとプロが選ぶ次の一歩
App Router は単独で勉強しても応用が利きません。React 19 / Suspense / Server Components / TanStack Query / Zod / 認証基盤と組み合わせて初めて武器になります。独学で詰まる代表ポイントは「キャッシュの破棄ができない」「Client / Server 境界の設計」「Server Actions と既存 API の整合」の 3 つで、ここは情報密度と質問できる環境が学習効率を大きく左右します。
もし「現場で通用するモダン Next.js を 3〜6 ヶ月で身につけたい」「副業・転職に直結するポートフォリオまで作りきりたい」のであれば、メンター付きスクールで Next.js / TypeScript / React フルスタックを一気に走り切るのが最短ルートです。以下は筆者が実際に評価しているスクールです。比較対象として 1〜2 社の無料カウンセリングを受けるだけでも、自分に足りない技能の輪郭がはっきり見えます。
- TechAcademy(テックアカデミー) ―― オンライン完結 / 現役エンジニアのマンツーマンメンタリング。フロントエンドコースで React + Next.js を実案件形式で組める。
- 侍エンジニア ―― オーダーメイドカリキュラム。希望すれば App Router + Server Components 中心の学習計画も組める。
- DMM WEBCAMP ―― 転職保証付き。React / TypeScript / Next.js のモダンスタックを未経験から実務レベルへ。
- レバテックキャリア ―― すでに実務経験がある人向け。Next.js / TypeScript / RSC 採用案件を扱う企業へのハイクラス転職に強い。
12. まとめ
App Router は「フォルダとファイル名が UI の役割を決める規約」という発想で、Pages Router 時代に散らかっていた _app / _document / getXxxProps / next/head / API Routes を一気に整理した、Next.js の現在地そのものです。本記事で押さえた 7 大ファイル規約(page / layout / loading / error / not-found / template / default)、6 種のルーティングセグメント((group) / _private / [id] / [...slug] / [[...slug]] / @slot / (.))、メタデータ API、Route Handler、middleware.ts、revalidatePath / revalidateTag、Server Component / Client Component の境界 ―― この 6 領域を押さえれば、App Router で実装できないアプリ要件はほとんど存在しません。
あとは手元のプロジェクトで app/ ディレクトリを作り、本記事のスニペットを 1 つずつコピペして動かしてみてください。理屈と現物が結びついたとき、App Router は「巨大な学習コスト」ではなく、「コードを書く前にディレクトリ構造で要件を表現できる強力な設計言語」として腹落ちするはずです。

コメント