nextjs-routing-advanced について
Next.js App Routerの高度なパターンを実装する開発者向けのリファレンススキルです。動的・キャッチオールルート、並列・インターセプトルート、ナビゲーション、Route Handlerを扱います。
主な機能
- 01動的ルートとキャッチオールルートのパターン
- 02ルートグループとプライベートフォルダー
- 03並列ルートとインターセプトルート
- 04プログラムによるナビゲーション方法
- 05HTTP Route Handlerのパターン
活用シーン
- 01slugを使ったブログや商品ページの構築
- 02並列ルートによるダッシュボードレイアウトの整理
- 03ギャラリー項目をモーダルルートで表示
- 04Route HandlerによるAPIエンドポイントの作成
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ josiahsiegel/ claude-plugin-marketplace --skill nextjs-routing-advancedインストール後、Claude Code で/nextjs-routing-advancedを入力して読み込まれたか確認します。
詳細
クイックリファレンス
| ルートの種類 | フォルダのパターン | URL の例 |
|---|---|---|
| 動的 | [slug] | /blog/hello → { slug: 'hello' } |
| キャッチオール | [...slug] | /docs/a/b → { slug: ['a', 'b'] } |
| オプショナルキャッチオール | [[...slug]] | /shop または /shop/a/b |
| ルートグループ | (name) | URL に影響せず、レイアウトをグループ化 |
| 並列ルート | @slot | 独立した読み込み/エラー処理 |
| 同じ階層でのインターセプト | (.)path | モーダルのパターン |
| プライベートフォルダ | _folder | ルートではありません |
| ナビゲーション | コード | 用途 |
|---|---|---|
| リンク | <Link href="/path"> | 宣言的なナビゲーション |
| router.push | router.push('/path') | プログラムによるナビゲーション |
| router.replace | router.replace('/path') | 履歴に追加しません |
| redirect | redirect('/path') | サーバーリダイレクト |
| ルートハンドラー | メソッド | パターン |
|---|---|---|
GET | 読み取り | export async function GET() {} |
POST | 作成 | export async function POST() {} |
PUT | 更新 | export async function PUT() {} |
DELETE | 削除 | export async function DELETE() {} |
このスキルを使用する場面
高度なルーティングパターンに使用します:
- スラッグを使った動的なブログ/商品ページ
- キャッチオールルートを使ったドキュメント
- 並列ルートを使ったダッシュボードのレイアウト
- インターセプトルートを使ったフォトギャラリーのモーダル
- ルートハンドラーを使った API エンドポイント
関連スキル:
- App Router の基本については、
nextjs-app-routerを参照してください - ミドルウェアルーティングについては、
nextjs-middlewareを参照してください - ルート内のデータについては、
nextjs-data-fetchingを参照してください
Next.js の高度なルーティング
動的ルート
単一の動的セグメント
// app/blog/[slug]/page.tsx
interface PageProps {
params: Promise<{ slug: string }>;
}
export default async function BlogPost({ params }: PageProps) {
const { slug } = await params;
const post = await getPost(slug);
return <article>{post.content}</article>;
}
// /blog/hello-world → { slug: 'hello-world' }
複数の動的セグメント
// app/shop/[category]/[product]/page.tsx
interface PageProps {
params: Promise<{ category: string; product: string }>;
}
export default async function ProductPage({ params }: PageProps) {
const { category, product } = await params;
const productData = await getProduct(category, product);
return <div>{productData.name}</div>;
}
// /shop/electronics/laptop → { category: 'electronics', product: 'laptop' }
キャッチオールセグメント
// app/docs/[...slug]/page.tsx
interface PageProps {
params: Promise<{ slug: string[] }>;
}
export default async function DocsPage({ params }: PageProps) {
const { slug } = await params;
// slug is an array of path segments
const doc = await getDoc(slug.join('/'));
return <div>{doc.content}</div>;
}
// /docs/getting-started → { slug: ['getting-started'] }
// /docs/api/auth/login → { slug: ['api', 'auth', 'login'] }
オプショナルキャッチオールセグメント
// app/shop/[[...slug]]/page.tsx
interface PageProps {
params: Promise<{ slug?: string[] }>;
}
export default async function ShopPage({ params }: PageProps) {
const { slug } = await params;
if (!slug) {
// /shop - show all products
return <AllProducts />;
}
if (slug.length === 1) {
// /shop/category - show category
return <CategoryProducts category={slug[0]} />;
}
// /shop/category/product - show product
return <ProductDetail category={slug[0]} product={slug[1]} />;
}
// /shop → { slug: undefined }
// /shop/electronics → { slug: ['electronics'] }
// /shop/electronics/laptop → { slug: ['electronics', 'laptop'] }
ルートグループ
URL に影響を与えずに整理する
app/
├── (marketing)/
│ ├── layout.tsx # Marketing layout
│ ├── about/
│ │ └── page.tsx # /about
│ └── contact/
│ └── page.tsx # /contact
│
├── (shop)/
│ ├── layout.tsx # Shop layout
│ ├── products/
│ │ └── page.tsx # /products
│ └── cart/
│ └── page.tsx # /cart
│
└── (auth)/
├── layout.tsx # Auth layout (centered, minimal)
├── login/
│ └── page.tsx # /login
└── register/
└── page.tsx # /register
複数のルートレイアウト
// app/(marketing)/layout.tsx
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
<MarketingHeader />
{children}
<MarketingFooter />
</body>
</html>
);
}
// app/(app)/layout.tsx
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
<AppSidebar />
<main>{children}</main>
</body>
</html>
);
}
並列ルート
基本的な並列ルート
app/
├── layout.tsx
├── page.tsx
├── @analytics/
│ ├── page.tsx
│ └── loading.tsx
├── @team/
│ ├── page.tsx
│ └── loading.tsx
└── @notifications/
└── page.tsx
// app/layout.tsx
export default function Layout({
children,
analytics,
team,
notifications,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
notifications: React.ReactNode;
}) {
return (
<div className="dashboard">
<main>{children}</main>
<aside>
{analytics}
{team}
{notifications}
</aside>
</div>
);
}
並列ルートを使った条件付きレンダリング
// app/layout.tsx
import { auth } from '@/lib/auth';
export default async function Layout({
children,
admin,
user,
}: {
children: React.ReactNode;
admin: React.ReactNode;
user: React.ReactNode;
}) {
const session = await auth();
return (
<div>
{children}
{session?.role === 'admin' ? admin : user}
</div>
);
}
デフォルトスロット
// app/@analytics/default.tsx
// Shown when the slot doesn't match current route
export default function AnalyticsDefault() {
return null; // or a default UI
}
インターセプトルート
モーダルのパターン
app/
├── feed/
│ └── page.tsx # /feed - main feed
├── photo/
│ └── [id]/
│ └── page.tsx # /photo/123 - full page photo
└── @modal/
└── (.)photo/
└── [id]/
└── page.tsx # Intercepted: shows modal
インターセプトの規約
(.) - Match same level
(..) - Match one level above
(..)(..) - Match two levels above
(...) - Match from root
フォトギャラリーのモーダルの例
// app/feed/page.tsx
import Link from 'next/link';
export default function FeedPage() {
const photos = await getPhotos();
return (
<div className="grid">
{photos.map((photo) => (
<Link key={photo.id} href={`/photo/${photo.id}`}>
<img src={photo.thumbnail} alt={photo.title} />
</Link>
))}
</div>
);
}
// app/@modal/(.)photo/[id]/page.tsx
import { Modal } from '@/components/modal';
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<Modal>
<img src={photo.url} alt={photo.title} />
<p>{photo.description}</p>
</Modal>
);
}
// app/photo/[id]/page.tsx - Full page view (direct navigation)
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<div className="photo-page">
<img src={photo.url} alt={photo.title} />
<h1>{photo.title}</h1>
<p>{photo.description}</p>
</div>
);
}
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
);
}
// components/modal.tsx
'use client';
import { useRouter } from 'next/navigation';
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
return (
<div className="modal-overlay" onClick={() => router.back()}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<button onClick={() => router.back()}>Close</button>
{children}
</div>
</div>
);
}
プライベートフォルダ
app/
├── _components/ # Private - not a route
│ ├── Button.tsx
│ └── Card.tsx
├── _lib/ # Private - not a route
│ └── utils.ts
├── dashboard/
│ ├── _components/ # Private - scoped to dashboard
│ │ └── Chart.tsx
│ └── page.tsx
└── page.tsx
ルートハンドラー
HTTP メソッド
// app/api/posts/route.ts
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const posts = await getPosts();
return NextResponse.json(posts);
}
export async function POST(request: Request) {
const body = await request.json();
const post = await createPost(body);
return NextResponse.json(post, { status: 201 });
}
export async function PUT(request: Request) {
const body = await request.json();
const post = await updatePost(body);
return NextResponse.json(post);
}
export async function DELETE(request: Request) {
await deletePost();
return new NextResponse(null, { status: 204 });
}
動的ルートハンドラー
// app/api/posts/[id]/route.ts
interface RouteContext {
params: Promise<{ id: string }>;
}
export async function GET(request: Request, context: RouteContext) {
const { id } = await context.params;
const post = await getPost(id);
if (!post) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(post);
}
ルートハンドラーのオプション
// Force dynamic
export const dynamic = 'force-dynamic';
// Set runtime
export const runtime = 'edge';
// Set revalidation
export const revalidate = 60;
URL クエリパラメーター
検索パラメーターへのアクセス
// app/search/page.tsx
interface SearchPageProps {
searchParams: Promise<{ q?: string; page?: string; sort?: string }>;
}
export default async function SearchPage({ searchParams }: SearchPageProps) {
const { q, page = '1', sort = 'relevance' } = await searchParams;
const results = await search({
query: q,
page: parseInt(page),
sort,
});
return (
<div>
<h1>Results for: {q}</h1>
<SearchResults results={results} />
</div>
);
}
クライアント側での URL 更新
'use client';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
export function SearchFilter() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const updateSearch = (key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set(key, value);
router.push(`${pathname}?${params.toString()}`);
};
return (
<select onChange={(e) => updateSearch('sort', e.target.value)}>
<option value="relevance">Relevance</option>
<option value="date">Date</option>
<option value="price">Price</option>
</select>
);
}
プログラムによるナビゲーション
useRouter フック
'use client';
import { useRouter } from 'next/navigation';
export function NavigationExample() {
const router = useRouter();
return (
<div>
<button onClick={() => router.push('/dashboard')}>
Go to Dashboard
</button>
<button onClick={() => router.replace('/login')}>
Replace with Login
</button>
<button onClick={() => router.back()}>
Go Back
</button>
<button onClick={() => router.forward()}>
Go Forward
</button>
<button onClick={() => router.refresh()}>
Refresh
</button>
<button onClick={() => router.prefetch('/about')}>
Prefetch About
</button>
</div>
);
}
redirect() 関数
// In Server Component or Server Action
import { redirect } from 'next/navigation';
export default async function ProtectedPage() {
const session = await getSession();
if (!session) {
redirect('/login');
}
return <div>Protected content</div>;
}
permanentRedirect() 関数
import { permanentRedirect } from 'next/navigation';
export default async function OldPage() {
permanentRedirect('/new-page'); // 308 status
}
ベストプラクティス
| プラクティス | 説明 |
|---|---|
| ルートグループを使って整理する | 機能またはレイアウトごとにグループ化します |
| 読み込み状態を実装する | 各セグメントに loading.tsx を追加します |
| ダッシュボードには並列ルートを使う | 読み込み/エラー状態を独立させます |
| モーダルにはインターセプトを使う | オーバーレイの UX が向上します |
| プライベートフォルダを整理しておく | ルートではない項目には _ 接頭辞を使います |
| パラメーターに型を付ける | params と searchParams には Promise<> を使います |
コメント
Markdown 対応