nextjs-app-router について
nextjs-app-routerは、Next.jsのApp Routerでアプリを開発するためのリファレンススキルです。ファイルベースのルーティング、レイアウト、ローディングやエラー画面、ナビゲーション、メタデータの規約を解説します。
主な機能
- 01App Routerのファイル規約
- 02動的ルートとルートグループ
- 03永続レイアウトとテンプレート
- 04ローディング状態とエラーバウンダリー
- 05静的・動的メタデータ
活用シーン
- 01新しいNext.js App Routerプロジェクトの構成
- 02動的ルートやルートグループの追加
- 03共有レイアウトとローディングUIの構築
- 04ルートエラーとページメタデータの処理
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ josiahsiegel/ claude-plugin-marketplace --skill nextjs-app-routerインストール後、Claude Code で/nextjs-app-routerを入力して読み込まれたか確認します。
詳細
クイックリファレンス
| 規約 | ファイル | 目的 |
|---|---|---|
| ページ | page.tsx | ルート UI(ルートに必須) |
| レイアウト | layout.tsx | 共通 UI(ページ遷移後も維持) |
| ローディング | loading.tsx | Suspense を使ったローディング UI |
| エラー | error.tsx | エラーバウンダリー('use client' が必須) |
| 見つからない場合 | not-found.tsx | 404 UI |
| テンプレート | template.tsx | ページ遷移時に再レンダリング |
| パターン | 構文 | 例 |
|---|---|---|
| 動的ルート | [slug] | /blog/[slug]/page.tsx |
| キャッチオール | [...slug] | /docs/[...slug]/page.tsx |
| オプショナルキャッチオール | [[...slug]] | /shop/[[...slug]]/page.tsx |
| ルートグループ | (name) | /(marketing)/about/page.tsx |
| 並列ルート | @slot | /@analytics/page.tsx |
このスキルを使う場面
App Router の基礎には、次の用途で使います。
- App Router を使った新しい Next.js 15 プロジェクトのセットアップ
- ファイルベースのルーティング規約の理解
- ページ遷移後も維持されるレイアウトの作成
- ローディング状態とエラーバウンダリーの実装
- グループを使ったルートの整理
関連スキル:
- データ取得については、
nextjs-data-fetchingを参照してください - 高度なルーティングについては、
nextjs-routing-advancedを参照してください - キャッシュ戦略については、
nextjs-cachingを参照してください
Next.js App Router の基礎
プロジェクト構成
App ディレクトリ
app/
├── layout.tsx # Root layout (required)
├── page.tsx # Home page (/)
├── loading.tsx # Loading UI
├── error.tsx # Error boundary
├── not-found.tsx # 404 page
├── global-error.tsx # Global error boundary
├── template.tsx # Re-renders on navigation
│
├── (marketing)/ # Route group (no URL impact)
│ ├── about/
│ │ └── page.tsx # /about
│ └── contact/
│ └── page.tsx # /contact
│
├── dashboard/
│ ├── layout.tsx # Dashboard layout
│ ├── page.tsx # /dashboard
│ ├── loading.tsx # Dashboard loading
│ ├── @analytics/ # Parallel route
│ │ └── page.tsx
│ ├── @team/ # Parallel route
│ │ └── page.tsx
│ └── settings/
│ └── page.tsx # /dashboard/settings
│
├── blog/
│ ├── page.tsx # /blog
│ └── [slug]/ # Dynamic route
│ ├── page.tsx # /blog/:slug
│ └── opengraph-image.tsx
│
├── products/
│ └── [...slug]/ # Catch-all route
│ └── page.tsx # /products/*
│
├── shop/
│ └── [[...slug]]/ # Optional catch-all
│ └── page.tsx # /shop or /shop/*
│
└── api/
└── users/
└── route.ts # API route handler
レイアウト
ルートレイアウト(必須)
// 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: {
template: '%s | My App',
default: 'My App',
},
description: 'My application description',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<header>
<nav>{/* Navigation */}</nav>
</header>
<main>{children}</main>
<footer>{/* Footer */}</footer>
</body>
</html>
);
}
ネストされたレイアウト
// app/dashboard/layout.tsx
import { Sidebar } from '@/components/sidebar';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="dashboard">
<Sidebar />
<div className="dashboard-content">{children}</div>
</div>
);
}
並列ルートを使ったレイアウト
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<div className="dashboard">
<div className="main">{children}</div>
<div className="sidebar">
{analytics}
{team}
</div>
</div>
);
}
ページ
基本的なページ
// app/page.tsx
export default function HomePage() {
return (
<div>
<h1>Welcome to My App</h1>
<p>This is the home page.</p>
</div>
);
}
非同期サーバーコンポーネントのページ
// app/posts/page.tsx
import { db } from '@/lib/db';
async function getPosts() {
return db.posts.findMany({
orderBy: { createdAt: 'desc' },
});
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<div>
<h1>Blog Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<a href={`/posts/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
</div>
);
}
動的ルートのページ
// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { db } from '@/lib/db';
interface PageProps {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: PageProps) {
const { slug } = await params;
const post = await db.posts.findUnique({ where: { slug } });
if (!post) {
return { title: 'Post Not Found' };
}
return {
title: post.title,
description: post.excerpt,
};
}
export async function generateStaticParams() {
const posts = await db.posts.findMany({ select: { slug: true } });
return posts.map((post) => ({ slug: post.slug }));
}
export default async function PostPage({ params }: PageProps) {
const { slug } = await params;
const post = await db.posts.findUnique({ where: { slug } });
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
ローディング UI
ローディングコンポーネント
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="loading-container">
<div className="spinner" />
<p>Loading dashboard...</p>
</div>
);
}
スケルトンローディング
// app/posts/loading.tsx
export default function PostsLoading() {
return (
<div className="posts-skeleton">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="post-skeleton">
<div className="skeleton-title" />
<div className="skeleton-excerpt" />
</div>
))}
</div>
);
}
Suspense を使ったストリーミング
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { Analytics } from './analytics';
import { RecentSales } from './recent-sales';
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics />
</Suspense>
<Suspense fallback={<SalesSkeleton />}>
<RecentSales />
</Suspense>
</div>
);
}
エラー処理
エラーバウンダリー
// app/dashboard/error.tsx
'use client';
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="error-container">
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
グローバルエラーバウンダリー
// app/global-error.tsx
'use client';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</body>
</html>
);
}
見つからない場合のページ
// app/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div className="not-found">
<h2>Page Not Found</h2>
<p>Could not find the requested resource.</p>
<Link href="/">Return Home</Link>
</div>
);
}
見つからない場合の処理を呼び出す
// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';
export default async function PostPage({ params }: PageProps) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
notFound();
}
return <article>{/* ... */}</article>;
}
メタデータ
静的メタデータ
// app/about/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'About Us',
description: 'Learn more about our company',
openGraph: {
title: 'About Us',
description: 'Learn more about our company',
images: ['/og-about.jpg'],
},
};
export default function AboutPage() {
return <div>About content</div>;
}
動的メタデータ
// app/products/[id]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next';
interface PageProps {
params: Promise<{ id: string }>;
}
export async function generateMetadata(
{ params }: PageProps,
parent: ResolvingMetadata
): Promise<Metadata> {
const { id } = await params;
const product = await getProduct(id);
const previousImages = (await parent).openGraph?.images || [];
return {
title: product.name,
description: product.description,
openGraph: {
images: [product.image, ...previousImages],
},
};
}
メタデータテンプレート
// app/layout.tsx
export const metadata: Metadata = {
title: {
template: '%s | My Store',
default: 'My Store',
},
metadataBase: new URL('https://mystore.com'),
};
// app/products/page.tsx
export const metadata: Metadata = {
title: 'Products', // Results in "Products | My Store"
};
テンプレートとレイアウトの違い
// app/dashboard/template.tsx
// Template re-mounts on navigation (state resets)
export default function DashboardTemplate({
children,
}: {
children: React.ReactNode;
}) {
return (
<div>
<nav>Dashboard Navigation</nav>
{children}
</div>
);
}
ルートグループ
// (marketing)/about/page.tsx → /about
// (marketing)/contact/page.tsx → /contact
// (shop)/products/page.tsx → /products
// (shop)/cart/page.tsx → /cart
// Different layouts for different sections
// app/(marketing)/layout.tsx - Marketing layout
// app/(shop)/layout.tsx - Shop layout
クライアントコンポーネント
// components/Counter.tsx
'use client';
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
}
サーバーコンポーネントとクライアントコンポーネントの組み合わせ
// app/page.tsx (Server Component)
import { Counter } from '@/components/Counter';
import { db } from '@/lib/db';
export default async function Page() {
const initialData = await db.getData();
return (
<div>
<h1>Server rendered title</h1>
<Counter />
<ClientDataDisplay data={initialData} />
</div>
);
}
ナビゲーション
Link コンポーネント
import Link from 'next/link';
export function Navigation() {
return (
<nav>
<Link href="/">Home</Link>
<Link href="/about">About</Link>
<Link href="/blog" prefetch={false}>Blog</Link>
<Link href="/dashboard" replace>Dashboard</Link>
</nav>
);
}
useRouter フック
'use client';
import { useRouter } from 'next/navigation';
export function NavigateButton() {
const router = useRouter();
return (
<button onClick={() => router.push('/dashboard')}>
Go to Dashboard
</button>
);
}
usePathname と useSearchParams
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
export function CurrentPath() {
const pathname = usePathname();
const searchParams = useSearchParams();
const query = searchParams.get('q');
return (
<div>
<p>Current path: {pathname}</p>
{query && <p>Search query: {query}</p>}
</div>
);
}
ベストプラクティス
| プラクティス | 説明 |
|---|---|
| サーバーコンポーネントをデフォルトにする | 必要な場合にのみ 'use client' を使います |
| 関連ファイルを同じ場所に配置する | page、loading、error をまとめて配置します |
| ルートグループを使う | URL に影響を与えずに整理します |
| ローディング状態を実装する | loading.tsx または Suspense を使います |
| エラーを適切に処理する | error.tsx のバウンダリーを使います |
| メタデータを最適化する | 動的ページには generateMetadata を使います |
コメント
Markdown 対応