nextjs-app-router-fundamentals について
nextjs-app-router-fundamentalsは、App Routerを使ったNext.js 13+アプリの開発や移行を支援するスキルです。ルーティング規約、レイアウト、メタデータ、データ取得などを解説し、`any`を避けるTypeScriptの指針も示します。
主な機能
- 01App Routerのファイルとルーティング規約
- 02Pages Routerからの移行ガイド
- 03レイアウトとネストされたルート
- 04静的・動的メタデータ
- 05サーバー・クライアントコンポーネントのパターン
- 06データ取得と静的ルート生成
活用シーン
- 01Next.js 13+ App Routerプロジェクトの開始
- 02Pages Routerからのルート移行
- 03ネストされた動的ルートの実装
- 04メタデータと静的ページの構築
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ wsimmonds/ claude-nextjs-skills --skill nextjs-app-router-fundamentalsインストール後、Claude Code で/nextjs-app-router-fundamentalsを入力して読み込まれたか確認します。
詳細
Next.js App Router の基本
概要
Next.js App Router(Next.js 13 以降)について、Pages Router からの移行、ファイルベースのルーティング規約、レイアウト、メタデータの処理、最新の Next.js パターンを網羅した包括的なガイダンスを提供します。
TypeScript:any 型は絶対に使用しないでください
重要なルール: このコードベースでは @typescript-eslint/no-explicit-any が有効になっています。any を使用するとビルドに失敗します。
❌ 誤り:
function handleSubmit(e: any) { ... }
const data: any[] = [];
✅ 正しい例:
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { ... }
const data: string[] = [];
よく使われる Next.js の型パターン
// Page props
function Page({ params }: { params: { slug: string } }) { ... }
function Page({ searchParams }: { searchParams: { [key: string]: string | string[] | undefined } }) { ... }
// Form events
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { ... }
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { ... }
// Server actions
async function myAction(formData: FormData) { ... }
このスキルを使用する場面
次のような場合にこのスキルを使用します:
- Pages Router(
pages/ディレクトリ)から App Router(app/ディレクトリ)に移行する - Next.js 13 以降のアプリケーションをゼロから作成する
- レイアウト、テンプレート、ネストされたルーティングを扱う
- メタデータと SEO の最適化を実装する
- App Router のルーティング規約に基づいて構築する
- ルートグループ、並列ルート、インターセプトルートの基本を扱う
基本概念
App Router と Pages Router
Pages Router(旧式 - Next.js 12 以前):
pages/
├── index.tsx # Route: /
├── about.tsx # Route: /about
├── _app.tsx # Custom App component
├── _document.tsx # Custom Document component
└── api/ # API routes
└── hello.ts # API endpoint: /api/hello
App Router(最新 - Next.js 13 以降):
app/
├── layout.tsx # Root layout (required)
├── page.tsx # Route: /
├── about/ # Route: /about
│ └── page.tsx
├── blog/
│ ├── layout.tsx # Nested layout
│ └── [slug]/
│ └── page.tsx # Dynamic route: /blog/:slug
└── api/ # Route handlers
└── hello/
└── route.ts # API endpoint: /api/hello
ファイル規約
App Router の特殊ファイル:
layout.tsx- セグメントとその子要素で共有される UI(状態を保持し、再レンダリングされません)page.tsx- ルート固有の UI。ルートを一般公開可能にしますloading.tsx- React Suspense を使用した読み込み中の UIerror.tsx- Error Boundary を使用したエラー UInot-found.tsx- 404 UItemplate.tsx-layoutに似ていますが、ナビゲーション時に再レンダリングされますroute.ts- API エンドポイント(Route Handlers)
コロケーション:
- コンポーネント、テスト、その他のファイルを
app/内に配置できます - 公開ルートを作成するのは
page.tsxとroute.tsのみです - その他のファイル(コンポーネント、ユーティリティ、テスト)はルーティングされません
移行ガイド:Pages Router から App Router へ
ステップ 1:現在の構成を把握する
既存の Pages Router の設定を確認します:
pages/ディレクトリの構成を確認する_app.tsxを特定する - グローバル状態、レイアウト、プロバイダーを処理します_document.tsxを特定する - HTML 構造をカスタマイズします- メタデータの使用状況を確認する(
next/head、<Head>コンポーネント) - すべてのルートと動的セグメントを一覧にする
ステップ 2:ルートレイアウトを作成する
app/layout.tsx を作成します。App Router のすべてのアプリケーションで必須です:
// app/layout.tsx
export const metadata = {
title: 'My App',
description: 'App description',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
移行時の注意:
_document.tsxの HTML 構造をlayout.tsxに移動します_app.tsxのグローバルプロバイダーやラッパーをlayout.tsxに移動します<Head>のメタデータをmetadataエクスポートに変換します- ルートレイアウトには
<html>タグと<body>タグを必ず含めてください
ステップ 3:ページをルートに移行する
シンプルなページの移行:
// Before: pages/index.tsx
import Head from 'next/head';
export default function Home() {
return (
<>
<Head>
<title>Home Page</title>
</Head>
<main>
<h1>Welcome</h1>
</main>
</>
);
}
// After: app/page.tsx
export default function Home() {
return (
<main>
<h1>Welcome</h1>
</main>
);
}
// Metadata moved to layout.tsx or exported here
export const metadata = {
title: 'Home Page',
};
ネストされたルートの移行:
// Before: pages/blog/[slug].tsx
export default function BlogPost() { ... }
// After: app/blog/[slug]/page.tsx
export default function BlogPost() { ... }
ステップ 4:ナビゲーションを更新する
アンカータグを Next.js の Link に置き換えます:
// Before (incorrect in App Router)
<a href="/about">About</a>
// After (correct)
import Link from 'next/link';
<Link href="/about">About</Link>
ステップ 5:Pages ディレクトリを整理する
移行後:
pages/ディレクトリからすべてのページファイルを削除します- API ルートをまだ移行しない場合は、
pages/api/を残します _app.tsxと_document.tsxを削除します(機能はレイアウトに移動済みです)- 空になった
pages/ディレクトリは必要に応じて削除します
メタデータの処理
静的メタデータ
// app/page.tsx or app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'My Page',
description: 'Page description',
keywords: ['nextjs', 'react'],
openGraph: {
title: 'My Page',
description: 'Page description',
images: ['/og-image.jpg'],
},
};
動的メタデータ
// app/blog/[slug]/page.tsx
export async function generateMetadata({
params
}: {
params: { slug: string }
}): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
};
}
レイアウトとネスト
ネストされたレイアウトの作成
// app/layout.tsx - Root layout
export default function RootLayout({ children }) {
return (
<html>
<body>
<Header />
{children}
<Footer />
</body>
</html>
);
}
// app/blog/layout.tsx - Blog layout
export default function BlogLayout({ children }) {
return (
<div>
<BlogSidebar />
<main>{children}</main>
</div>
);
}
レイアウトの動作:
- レイアウトはナビゲーションをまたいで状態を保持します
- ルート変更時にレイアウトは再レンダリングされません
- 親レイアウトは子レイアウトをラップします
- ルートレイアウトは必須で、アプリ全体をラップします
ルーティングパターン
動的ルート
// app/blog/[slug]/page.tsx
export default function BlogPost({
params
}: {
params: { slug: string }
}) {
return <article>Post: {params.slug}</article>;
}
キャッチオールルート
// app/shop/[...slug]/page.tsx - Matches /shop/a, /shop/a/b, etc.
export default function Shop({
params
}: {
params: { slug: string[] }
}) {
return <div>Path: {params.slug.join('/')}</div>;
}
オプショナルキャッチオール
// app/shop/[[...slug]]/page.tsx - Matches /shop AND /shop/a, /shop/a/b
ルートグループ
URL に影響を与えずにルートをグループ化します:
app/
├── (marketing)/
│ ├── about/
│ │ └── page.tsx # /about
│ └── contact/
│ └── page.tsx # /contact
└── (shop)/
└── products/
└── page.tsx # /products
よくある移行時の落とし穴
落とし穴 1:ルートレイアウトの HTML タグを忘れる
誤り:
export default function RootLayout({ children }) {
return <div>{children}</div>; // Missing <html> and <body>
}
正しい例:
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
落とし穴 2:App Router で next/head を使用する
誤り:
import Head from 'next/head';
export default function Page() {
return (
<>
<Head><title>Title</title></Head>
<main>Content</main>
</>
);
}
正しい例:
export const metadata = { title: 'Title' };
export default function Page() {
return <main>Content</main>;
}
落とし穴 3:Pages ディレクトリを削除しない
ルートの移行後は、混乱を避けるため古い pages/ ディレクトリのファイルを削除してください。ルートが競合している場合、ビルドに失敗します。
落とし穴 4:page.tsx ファイルの不足
page.tsx ファイルがないと、ルートにはアクセスできません。レイアウトだけではルートは作成されません。
app/
├── blog/
│ ├── layout.tsx # NOT a route
│ └── page.tsx # This makes /blog accessible
落とし穴 5:リンクの使い方が正しくない
誤り:
<a href="/about">About</a> // Works but causes full page reload
正しい例:
import Link from 'next/link';
<Link href="/about">About</Link> // Client-side navigation
サーバーコンポーネントとクライアントコンポーネント
デフォルト:サーバーコンポーネント
app/ 内のすべてのコンポーネントは、デフォルトでサーバーコンポーネントです。
// app/page.tsx - Server Component (default)
export default async function Page() {
const data = await fetch('https://api.example.com/data');
const json = await data.json();
return <div>{json.title}</div>;
}
メリット:
- async/await を直接使用できます
- データベース/API に直接アクセスできます
- クライアント側の JavaScript が不要です
- コード分割が自動的に行われます
クライアントコンポーネント
次の機能が必要な場合は、'use client' ディレクティブを使用します。
- インタラクティブな要素(onClick、onChange など)
- React フック(useState、useEffect、useContext など)
- ブラウザー API(window、localStorage など)
- イベントリスナー
// app/components/Counter.tsx
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
データ取得パターン
サーバーコンポーネントでのデータ取得
// app/posts/page.tsx
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 } // Revalidate every hour
});
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
並列データ取得
export default async function Page() {
// Fetch in parallel
const [posts, users] = await Promise.all([
fetch('https://api.example.com/posts').then(r => r.json()),
fetch('https://api.example.com/users').then(r => r.json()),
]);
return (/* render */);
}
generateStaticParams による静的サイト生成
概要
generateStaticParams は、Pages Router の getStaticPaths に相当する App Router の機能です。動的ルートの静的ページをビルド時に生成します。
基本的な使い方
// app/blog/[id]/page.tsx
export async function generateStaticParams() {
// Return array of params to pre-render
return [
{ id: '1' },
{ id: '2' },
{ id: '3' },
];
}
export default function BlogPost({
params
}: {
params: { id: string }
}) {
return <article>Blog post {params.id}</article>;
}
要点:
- ルートパラメーターのキーを含むオブジェクトの配列を返します
- 各オブジェクトは、ビルド時に事前レンダリングするページを表します
- 関数はエクスポートし、
generateStaticParamsという名前にする必要があります - サーバーコンポーネントでのみ動作します(
'use client'ディレクティブは使用できません) - Pages Router の
getStaticPathsに代わるものです
静的パラメーター用のデータ取得
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return posts.map((post: { slug: string }) => ({
slug: post.slug,
}));
}
export default async function BlogPost({
params
}: {
params: { slug: string }
}) {
const post = await fetch(`https://api.example.com/posts/${params.slug}`).then(r => r.json());
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
複数の動的セグメント
// app/products/[category]/[id]/page.tsx
export async function generateStaticParams() {
const categories = await getCategories();
const params = [];
for (const category of categories) {
const products = await getProducts(category.slug);
for (const product of products) {
params.push({
category: category.slug,
id: product.id,
});
}
}
return params;
}
export default function ProductPage({
params
}: {
params: { category: string; id: string }
}) {
return <div>Category: {params.category}, Product: {params.id}</div>;
}
動的動作の設定
// app/blog/[id]/page.tsx
export async function generateStaticParams() {
return [{ id: '1' }, { id: '2' }];
}
// Control behavior for non-pre-rendered paths
export const dynamicParams = true; // default - allows runtime generation
// export const dynamicParams = false; // returns 404 for non-pre-rendered paths
export default function BlogPost({
params
}: {
params: { id: string }
}) {
return <article>Post {params.id}</article>;
}
オプション:
dynamicParams = true(デフォルト):事前レンダリングされていないパスをオンデマンドで生成しますdynamicParams = false:事前レンダリングされていないパスは 404 を返します
一般的なパターン
パターン 1:ID ベースのシンプルなルート
export async function generateStaticParams() {
return [
{ id: '1' },
{ id: '2' },
{ id: '3' },
];
}
パターン 2:API から取得
export async function generateStaticParams() {
const items = await fetch('https://api.example.com/items').then(r => r.json());
return items.map(item => ({ id: item.id }));
}
パターン 3:データベースクエリ
export async function generateStaticParams() {
const posts = await db.post.findMany();
return posts.map(post => ({ slug: post.slug }));
}
Pages Router からの移行
変更前(Pages Router):
// pages/blog/[id].tsx
export async function getStaticPaths() {
return {
paths: [
{ params: { id: '1' } },
{ params: { id: '2' } },
],
fallback: false,
};
}
export async function getStaticProps({ params }) {
return { props: { id: params.id } };
}
変更後(App Router):
// app/blog/[id]/page.tsx
export async function generateStaticParams() {
return [
{ id: '1' },
{ id: '2' },
];
}
export const dynamicParams = false; // equivalent to fallback: false
export default function BlogPost({ params }: { params: { id: string } }) {
return <div>Post {params.id}</div>;
}
避けるべきよくある間違い
❌ 誤り:'use client' を使用する
'use client'; // ERROR! generateStaticParams only works in Server Components
export async function generateStaticParams() {
return [{ id: '1' }];
}
❌ 誤り:Pages Router のパターンを使用する
export async function getStaticPaths() { // Wrong API!
return { paths: [...], fallback: false };
}
❌ 誤り:export キーワードがない
async function generateStaticParams() { // Must be exported!
return [{ id: '1' }];
}
✅ 正しい例:シンプルなサーバーコンポーネント
// app/blog/[id]/page.tsx
// No 'use client' directive
export async function generateStaticParams() {
return [{ id: '1' }, { id: '2' }];
}
export default function Page({ params }: { params: { id: string } }) {
return <div>Post {params.id}</div>;
}
実装に関する重要な注意:
generateStaticParams の「作成」または「実装」を依頼された場合:
- 必ず Edit または Write ツールを使って実際のファイルを変更してください
- 必ず 既存の page.tsx ファイルに関数を追加してください
- 必ず generateStaticParams に関する TODO コメントを削除してください
- コードを Markdown で出力するだけではいけません。実際に実装してください
- ファイルに書き込まずにコードを提示してはいけません
テストと検証
App Router への移行時または App Router でビルドする際は、次の点を確認してください。
-
構成:
app/ディレクトリが存在する<html>と<body>を含むルートlayout.tsxが存在する- 各ルートに
page.tsxファイルがある
-
メタデータ:
- App Router 内に
next/headのインポートがない - ページまたはレイアウトからメタデータがエクスポートされている
- メタデータが
Metadata型で正しく型付けされている
- App Router 内に
-
ナビゲーション:
next/linkのLinkコンポーネントを使用している- 内部ナビゲーションに通常の
<a>タグを使用していない
-
クリーンアップ:
pages/ディレクトリにページファイルが残っていない_app.tsxと_document.tsxが削除されている- 古いメタデータのパターンが削除されている
クイックリファレンス
ファイル構成の対応表
| Pages Router | App Router | 目的 |
|---|---|---|
pages/index.tsx | app/page.tsx | ホームルート |
pages/about.tsx | app/about/page.tsx | About ルート |
pages/[id].tsx | app/[id]/page.tsx | 動的ルート |
pages/_app.tsx | app/layout.tsx | グローバルレイアウト |
pages/_document.tsx | app/layout.tsx | HTML 構造 |
pages/api/hello.ts | app/api/hello/route.ts | API ルート |
よく使うコマンド
# Create new Next.js app with App Router
npx create-next-app@latest my-app
# Run development server
npm run dev
# Build for production
npm run build
# Start production server
npm start
追加リソース
より高度なルーティングパターン(並列ルート、インターセプトルート、ルートハンドラー)については、nextjs-advanced-routing スキルを参照してください。
サーバーコンポーネントとクライアントコンポーネントのベストプラクティスやアンチパターンについては、nextjs-server-client-components および nextjs-anti-patterns スキルを参照してください。