mcpField
N
nextjs-routing-advanced

nextjs-routing-advanced

github.com/josiahsiegel開発5972 回インストール19 週間前に更新
リポジトリ

nextjs-routing-advanced について

Next.js App Routerの高度なパターンを実装する開発者向けのリファレンススキルです。動的・キャッチオールルート、並列・インターセプトルート、ナビゲーション、Route Handlerを扱います。

主な機能

  1. 01動的ルートとキャッチオールルートのパターン
  2. 02ルートグループとプライベートフォルダー
  3. 03並列ルートとインターセプトルート
  4. 04プログラムによるナビゲーション方法
  5. 05HTTP Route Handlerのパターン

活用シーン

  1. 01slugを使ったブログや商品ページの構築
  2. 02並列ルートによるダッシュボードレイアウトの整理
  3. 03ギャラリー項目をモーダルルートで表示
  4. 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.pushrouter.push('/path')プログラムによるナビゲーション
router.replacerouter.replace('/path')履歴に追加しません
redirectredirect('/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<> を使います

コメント