mcpField
N
nextjs-advanced-routing

nextjs-advanced-routing

github.com/wsimmonds開発109334 回インストール53 週間前に更新
リポジトリ

nextjs-advanced-routing について

Next.js App Routerで高度なルーティング機能を構築する開発者向けのコーディングスキルです。Route Handlers、Server Actions、並列・インターセプトルート、エラー処理、ドラフトモード、Suspenseによるストリーミングを扱います。

主な機能

  1. 01Route Handlersの作成
  2. 02Server Actionsの実装
  3. 03並列ルートとインターセプトルートの構築
  4. 04エラーとドラフトモードの処理
  5. 05Suspenseによるコンテンツのストリーミング
  6. 06TypeScriptの`any`を回避

活用シーン

  1. 01App RouterプロジェクトへのAPIエンドポイント追加
  2. 02フォーム送信とサーバー側の変更処理
  3. 03モーダルやドロワーのルーティング構築
  4. 04ストリーミング応答とエラーバウンダリーの設定

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/wsimmonds/claude-nextjs-skills --skill nextjs-advanced-routing

インストール後、Claude Code で/nextjs-advanced-routingを入力して読み込まれたか確認します。

詳細

Next.js の高度なルーティング

概要

Route Handlers(API ルート)、Parallel Routes、Intercepting Routes、Server Actions、エラー処理、ドラフトモード、Suspense を使ったストリーミングなど、Next.js App Router の高度な機能について包括的なガイダンスを提供します。

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) { ... }

このスキルを使う場面

次のような場合に、このスキルを使います。

  • Route Handlers で API エンドポイントを作成するとき
  • parallel route または intercepting route を実装するとき
  • Server Actions でフォームを構築するとき
  • Cookie を設定したり、ミューテーションを処理したりするとき
  • エラーバウンダリーを作成するとき
  • CMS のプレビュー用にドラフトモードを実装するとき
  • ストリーミングと Suspense 境界を設定するとき
  • モーダルやドロワーなど、複雑なルーティングパターンを構築するとき

⚠️ 重要:Server Action のファイル名と配置場所

作業要件で特定のファイル名が指定されている場合は、その指示に正確に従ってください。指定がない場合は、プロジェクトの規約に最も合う選択肢を選びます。アクションをまとめるファイルには app/actions.ts が無難なデフォルトで、単一のフォームハンドラーには app/action.ts が適しています。

action.ts と actions.ts の選び方
  • 既存のパターンに合わせる: プロジェクトにすでにアクションファイルがあるかを確認し、適切であればそこに追加します。
  • エクスポート数に合わせる: 単一のアクションには action.ts を、関連するアクションのグループには actions.ts を使います。
  • 明示された要件に従う: 関係者から特定の名前が指定されている場合は、変更しないでください。

配置のガイドライン

  • Server Actions は App Router ツリーに組み込めるよう、app/ ディレクトリ内に配置します。
  • 複数のルートで共有しない限り、そのアクションを呼び出す UI と同じ場所にファイルを配置します。
  • 複数の離れたルートから呼び出され、サーバー専用のユーティリティとして使われる場合を除き、アクションを lib/ や utils/ に配置するのは避けてください。

配置例

app/
├── actions.ts       ← Shared actions that support multiple routes
└── dashboard/
    └── action.ts    ← Route-specific action colocated with a single page
例:action.ts の作成
// app/action.ts (single-action example)
'use server';

export async function submitForm(formData: FormData) {
  const name = formData.get('name') as string;
  // Process the form
  console.log('Submitted:', name);
}
例:actions.ts の作成
// app/actions.ts (multiple related actions)
'use server';

export async function createPost(formData: FormData) {
  // ...
}

export async function deletePost(id: string) {
  // ...
}

覚えておいてください: プロジェクト要件でファイル名が明示されている場合は、その名前に正確に合わせてください。

⚠️ 重要:Server Actions の戻り値の型 — フォームアクションは必ず void を返す

これは TypeScript の要件であり、任意ではありません。フォームアクションからデータを返すコードを見かけても、そのコードは誤りです。

フォームの action 属性に <form action={serverAction}> を使う場合:

  • 関数に return 文を含めてはいけません(暗黙的に void を返します)
  • TypeScript は return undefined や return null も含め、あらゆる戻り値を拒否します
  • 重要: コードベース内にフォームアクションからデータを返すサンプルコードがあっても、無視してください。これはアンチパターンです。return 文を削除して修正してください。

❌ 誤り(ビルドエラーになります):

export async function saveForm(formData: FormData) {
  'use server';

  const name = formData.get('name') as string;
  if (!name) throw new Error('Name required');

  await db.save(name);
  return { success: true }; // ❌ BUILD ERROR: Type mismatch
}

// In component:
<form action={saveForm}>  {/* ❌ Expects void function */}
  <input name="name" />
</form>

✅ 正しい例 — 方法 1(シンプルなフォームアクション。応答なし):

export async function saveForm(formData: FormData) {
  'use server';

  const name = formData.get('name') as string;

  // Validate - throw errors instead of returning them
  if (!name) throw new Error('Name required');

  await db.save(name);
  revalidatePath('/'); // Trigger UI update
  // No return statement - returns void implicitly
}

// In component:
<form action={saveForm}>
  <input name="name" required />
  <button type="submit">Save</button>
</form>

✅ 正しい例 — 方法 2(フィードバックに useActionState を使用):

export async function saveForm(prevState: any, formData: FormData) {
  'use server';

  const name = formData.get('name') as string;
  if (!name) return { error: 'Name required' };

  await db.save(name);
  return { success: true, message: 'Saved!' }; // ✅ OK with useActionState
}

// In component:
'use client';
const [state, action] = useActionState(saveForm, null);

return (
  <form action={action}>
    <input name="name" required />
    <button type="submit">Save</button>
    {state?.error && <p>{state.error}</p>}
    {state?.success && <p>{state.message}</p>}
  </form>
);

重要なルール: <form action={...}> は void を想定しています。データを返す必要がある場合は、useActionState を使ってください。

Route Handlers(API ルート)

基本的な Route Handler

Route Handlers は Pages Router の API Routes に代わるものです。route.ts または route.js ファイル内に作成します。

// app/api/hello/route.ts
export async function GET(request: Request) {
  return Response.json({ message: 'Hello World' });
}

export async function POST(request: Request) {
  const body = await request.json();

  return Response.json({
    message: 'Data received',
    data: body
  });
}
サポートされている HTTP メソッド
// app/api/items/route.ts
export async function GET(request: Request) { }
export async function POST(request: Request) { }
export async function PUT(request: Request) { }
export async function PATCH(request: Request) { }
export async function DELETE(request: Request) { }
export async function HEAD(request: Request) { }
export async function OPTIONS(request: Request) { }
動的な Route Handlers
// app/api/posts/[id]/route.ts
export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const id = params.id;
  const post = await db.posts.findUnique({ where: { id } });

  return Response.json(post);
}

export async function DELETE(
  request: Request,
  { params }: { params: { id: string } }
) {
  await db.posts.delete({ where: { id: params.id } });

  return Response.json({ success: true });
}
リクエストヘッダーと Cookie
// app/api/profile/route.ts
import { cookies, headers } from 'next/headers';

export async function GET(request: Request) {
  // Access headers
  const headersList = await headers();
  const authorization = headersList.get('authorization');

  // Access cookies
  const cookieStore = await cookies();
  const sessionToken = cookieStore.get('session-token');

  if (!sessionToken) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const user = await fetchUser(sessionToken.value);

  return Response.json(user);
}
Route Handlers での Cookie の設定
// app/api/login/route.ts
import { cookies } from 'next/headers';

export async function POST(request: Request) {
  const { email, password } = await request.json();

  const token = await authenticate(email, password);

  if (!token) {
    return Response.json({ error: 'Invalid credentials' }, { status: 401 });
  }

  // Set cookie
  const cookieStore = await cookies();
  cookieStore.set('session-token', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    maxAge: 60 * 60 * 24 * 7, // 1 week
    path: '/',
  });

  return Response.json({ success: true });
}
CORS の設定
// app/api/public/route.ts
export async function GET(request: Request) {
  const data = await fetchData();

  return Response.json(data, {
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    },
  });
}

export async function OPTIONS(request: Request) {
  return new Response(null, {
    status: 204,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    },
  });
}
ストリーミング応答
// app/api/stream/route.ts
export async function GET() {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 10; i++) {
        controller.enqueue(encoder.encode(`data: ${i}\n\n`));
        await new Promise(resolve => setTimeout(resolve, 1000));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}

Server Actions

Server Actions を使うと、API エンドポイントを作成せずにサーバー側のミューテーションを実行できます。

基本的な Server Action(戻り値なし)
// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  const post = await db.posts.create({
    data: { title, content },
  });

  revalidatePath('/posts');
  // No return statement - Server Actions with forms should return void
}

注: データを返すパターンと返さないパターンについては、以下の「フォームでの Server Actions の使用」セクションを参照してください。

⚠️ 重要:サーバーアクションファイルの構成

ファイル名の正確さ:

  • ステークホルダーがファイル名(例:「action.tsという名前のファイルにサーバーアクションを作成してください」)を指定した場合は、正確にその名前を使用してください。
  • 一般的なファイル名は、action.ts(単数形)またはactions.ts(複数形)です。要件または既存のコードに合う方を選択してください。
  • ファイルは適切なディレクトリに配置してください。通常はapp/action.tsまたはapp/actions.tsです。

「use server」ディレクティブの2つのパターン:

パターン1:ファイルレベル(複数のアクションに推奨):

// app/actions.ts
'use server';  // At the top - ALL exports are server actions

export async function createPost(formData: FormData) { ... }
export async function updatePost(formData: FormData) { ... }
export async function deletePost(postId: string) { ... }

パターン2:関数レベル(単一のアクション、または複数の種類が混在するファイル向け):

// app/action.ts or any file
export async function createPost(formData: FormData) {
  'use server';  // Inside the function - ONLY this function is a server action

  const title = formData.get('title') as string;
  await db.posts.create({ data: { title } });
}

サーバーアクションを呼び出すクライアントコンポーネント:

クライアントコンポーネントからサーバーアクションを呼び出す必要がある場合(例:onClick、フォーム送信):

  1. 'use server'ディレクティブを含むサーバーアクションを別のファイルに作成します
  2. クライアントコンポーネントでインポートして使用します

✅ 正しいパターン:

// app/actions.ts - Server Actions file
'use server';

import { cookies } from 'next/headers';

export async function updateUserPreference(key: string, value: string) {
  const cookieStore = await cookies();
  cookieStore.set(key, value);

  // Or perform other server-side operations
  await db.userSettings.update({ [key]: value });
}

// app/InteractiveButton.tsx - Client Component
'use client';

import { updateUserPreference } from './actions';

export default function InteractiveButton() {
  const handleClick = () => {
    updateUserPreference('theme', 'dark');
  };

  return (
    <button onClick={handleClick}>
      Update Preference
    </button>
  );
}

❌ 間違い:「use server」と「use client」を同じファイルに混在させる:

// app/CookieButton.tsx
'use client';  // This file is a client component

export async function setCookie() {
  'use server';  // ERROR! Can't have server actions in client component file
  // ...
}
フォームでのサーバーアクションの使用 — 2つのパターン
パターン1:シンプルなフォームアクション(voidを返す/エラーをスローする)

重要: フォームのaction属性を直接使用する場合、サーバーアクションは必ずvoid(何も返さない)を返す必要があります。{ success: true }やその他のオブジェクトを返さないでください。

検証ルール: すべての入力をチェックし、検証に失敗した場合はエラーをスローしてください。エラーオブジェクトを返さないでください。

⚠️ 重要: コードベース内に、フォームアクションから{ success: true }を返すサンプルコードがあっても、そのパターンをコピーしないでください。これはアンチパターンです。必ず次の手順に従ってください。

  1. 入力をチェック/検証します
  2. 検証に失敗した場合はエラーをスローします(エラーオブジェクトは返しません)
  3. リクエストを処理します
  4. 何も返しません(voidを返します)

フォームアクションの正しいパターン:

// app/actions.ts
'use server';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  // Validate
  if (!title || !content) {
    throw new Error('Title and content are required');
  }

  // Save to database
  await db.posts.create({ data: { title, content } });

  // Revalidate or redirect - no return needed
  revalidatePath('/posts');
}

// app/posts/new/page.tsx
import { createPost } from '@/app/actions';

export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Title" required />
      <textarea name="content" placeholder="Content" required />
      <button type="submit">Create Post</button>
    </form>
  );
}
パターン2:useActionStateを使うフォーム(データを返す)

成功/エラーメッセージを表示する必要がある場合は、useActionStateを使用します。

// app/actions.ts
'use server';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  if (!title || !content) {
    return { success: false, error: 'Title and content required' };
  }

  const post = await db.posts.create({ data: { title, content } });
  return { success: true, post };
}

// app/posts/new/page.tsx
'use client';

import { createPost } from '@/app/actions';
import { useActionState } from 'react';

export default function NewPost() {
  const [state, action, isPending] = useActionState(createPost, null);

  return (
    <form action={action}>
      <input name="title" placeholder="Title" required />
      <textarea name="content" placeholder="Content" required />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Post'}
      </button>
      {state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
      {state?.success && <p style={{ color: 'green' }}>Post created!</p>}
    </form>
  );
}

主な違い:

  • パターン1: フォームアクションのみを使用し、サーバーアクションはvoidを返し、revalidatePathを使用します
  • パターン2: useActionStateを使用し、サーバーアクションは表示用のデータを返します
フォーム検証の例 — 複数のフィールド

必須フィールドが複数ある場合は、すべてをまとめてチェックし、いずれかが欠けていればエラーをスローします。

'use server';

export async function saveContactMessage(formData: FormData) {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;
  const message = formData.get('message') as string;

  // Validate all fields - throw if any are missing
  if (!name || !email || !message) {
    throw new Error('All fields are required');
  }

  // Save to database
  console.log('Saving contact message:', { name, email, message });

  // No return - returns void implicitly
}

これにより、次のことが実現できます。

  1. ✅ TypeScriptのチェックに合格します(voidを返します)
  2. ✅ 処理前にすべての入力を検証します
  3. ✅ 検証に失敗した場合はエラーをスローします(データベースへの保存を防ぎます)
  4. ✅ エラーオブジェクトを返しません(返すとフォームアクションの型付けに問題が生じます)
クライアントコンポーネントでのサーバーアクション
// app/actions.ts
'use server';

export async function updateUsername(userId: string, username: string) {
  await db.users.update({
    where: { id: userId },
    data: { username },
  });

  return { success: true };
}

// app/components/UsernameForm.tsx
'use client';

import { updateUsername } from '@/app/actions';
import { useState } from 'react';

export default function UsernameForm({ userId }: { userId: string }) {
  const [username, setUsername] = useState('');
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);

    await updateUsername(userId, username);

    setLoading(false);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={username}
        onChange={(e) => setUsername(e.target.value)}
        placeholder="New username"
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Updating...' : 'Update'}
      </button>
    </form>
  );
}
検証を伴うサーバーアクション(エラーをスローする)

フォームアクションを直接使用する場合、検証に失敗したときはエラーをスローしてください(エラーオブジェクトは返しません)。

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  // Validation - throw error if invalid
  if (!title || !content) {
    throw new Error('Title and content are required');
  }

  if (title.length > 100) {
    throw new Error('Title must be less than 100 characters');
  }

  if (content.length < 10) {
    throw new Error('Content must be at least 10 characters');
  }

  // Save to database
  const post = await db.posts.create({
    data: { title, content },
  });

  revalidatePath('/posts');
  // No return - form actions return void
}

検証状態を返す場合: 検証エラーを返す、またはUIに表示する必要がある場合は、代わりにuseActionState(上記のパターン2)を使用してください。

サーバーアクションでのCookieの設定
// app/actions.ts
'use server';

import { cookies } from 'next/headers';

export async function setTheme(theme: 'light' | 'dark') {
  const cookieStore = await cookies();

  cookieStore.set('theme', theme, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    maxAge: 60 * 60 * 24 * 365, // 1 year
    path: '/',
  });

  return { success: true };
}
再検証とリダイレクト
// app/actions.ts
'use server';

import { revalidatePath, revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';

export async function deletePost(postId: string) {
  await db.posts.delete({ where: { id: postId } });

  // Revalidate specific path
  revalidatePath('/posts');

  // Or revalidate by cache tag
  revalidateTag('posts');

  // Redirect after deletion
  redirect('/posts');
}

ステップ0:並列ルートの適用範囲を決める

並列ルートを実装する前に、ルート構造のどこに配置すべきかを特定します。

ルートのコンテキストを分析する

重要な質問: この機能は特定のページ/セクション向けですか、それともアプリケーション全体向けですか?

  • 特定のページ/セクション → 該当するルートディレクトリの下に作成します
  • アプリケーション全体 → ルートレベルに作成します
ルートの適用範囲を決める手順

要件で特定の機能またはページに言及している場合:

"Create a [feature-name] with parallel routes for X and Y"
→ Structure: app/[feature-name]/@x/ and app/[feature-name]/@y/

要件がアプリ全体のレイアウトを対象としている場合:

"Create an app with parallel routes for X and Y"
→ Structure: app/@x/ and app/@y/
よくある適用範囲の誤り

❌ 間違い — 並列ルートの適用範囲が不適切:

Request: "Create a [specific-feature] with sections for X and Y"

app/
├── @x/              # ❌ Created at root - affects entire app!
├── @y/              # ❌ Wrong scope
└── layout.tsx       # ❌ Root layout modified unnecessarily

この場合、機能固有であるべき並列ルートがグローバルになります。

✅ 正しい — 並列ルートが適切な範囲に設定されている:

Request: "Create a [specific-feature] with sections for X and Y"

app/
├── [specific-feature]/
│   ├── @x/          # ✅ Scoped to this feature
│   ├── @y/          # ✅ Only affects this route
│   └── layout.tsx   # ✅ Feature-specific layout
└── layout.tsx       # Root layout unchanged
判断基準
  1. 要件を分析する - 具体的な機能名やページ名を探します

    • 特定の名詞や機能への言及がありますか? → app/[that-feature]/ の下に作成します
    • アプリ全体に関する説明ですか? → ルートにするか、新しいルートにするか判断します
  2. URL 構造を検討する - どの URL に配置すべきですか?

    • /feature パス → app/feature/@slots/ を使用します
    • ルート / パス → app/@slots/ を使用します
    • ネストされた /parent/feature → app/parent/feature/@slots/ を使用します
  3. スコープへの影響を考える - アプリのどの範囲に影響しますか?

    • 1 つの機能やページのみですか? → 機能ディレクトリ内にスコープを限定します
    • 関連する複数のページですか? → 親ディレクトリにスコープを設定します
    • アプリ全体ですか? → ルートレベルを使用します
実践例

例 1:機能固有の並列ルート

Scenario: a user profile page needs tabs for posts and activity

Analysis:
- "user profile page" = specific feature
- Should be at /profile URL
- Only affects profile page

Structure:
app/
├── profile/
│   ├── @posts/
│   │   └── page.tsx
│   ├── @activity/
│   │   └── page.tsx
│   └── layout.tsx        # Accepts posts, activity slots

例 2:アプリ全体の並列ルート

Scenario: the overall application layout must expose sidebar and main content slots

Analysis:
- "application layout" = root level
- Affects entire app
- Should be at root

Structure:
app/
├── @sidebar/
│   └── page.tsx
├── @main/
│   └── page.tsx
└── layout.tsx            # Root layout with slots

例 3:ネストされたセクションの並列ルート

Scenario: the admin area adds an analytics view with charts and tables

Analysis:
- "admin panel" = existing section
- "analytics view" = subsection
- Should be at /admin/analytics URL

Structure:
app/
├── admin/
│   ├── analytics/
│   │   ├── @charts/
│   │   │   └── page.tsx
│   │   ├── @tables/
│   │   │   └── page.tsx
│   │   └── layout.tsx    # Analytics-specific layout
│   └── layout.tsx        # Admin layout (unchanged)
クイックリファレンス
要件のパターンルートのスコープ構造の例
機能固有の要件app/[feature]/app/profile/@tab/
親領域内のセクションapp/[parent]/[section]/app/admin/analytics/@view/
アプリ全体のレイアウト要件app/app/@sidebar/
複数のパネルがあるページapp/[page]/app/settings/@panel/

重要なルール: ルートレベルの並列ルートを既定にする前に、必ずスコープを示す要件を分析してください。

並列ルート

並列ルートを使用すると、同じレイアウト内で複数のページを同時にレンダリングできます。

⚠️ 重要:まずルートのスコープを理解する

並列ルートを作成する前に、上記の「ステップ 0:並列ルートのスコープを決定する」を確認し、適切なディレクトリレベルを特定してください。

並列ルートをルートレベルに作成するのを既定にせず、要件で言及されている機能やページに適切にスコープを設定してください。

並列ルートの作成(機能スコープ)

機能固有の並列ルート(最も一般的なケース)の場合:

app/
├── [feature-name]/
│   ├── @slot1/
│   │   └── page.tsx
│   ├── @slot2/
│   │   └── page.tsx
│   ├── layout.tsx       # Feature layout accepting slot props
│   └── page.tsx         # Feature main page
並列ルートの作成(ルートレベル)

アプリ全体の並列ルート(あまり一般的ではないケース)の場合:

app/
├── @slot1/
│   └── page.tsx
├── @slot2/
│   └── page.tsx
├── layout.tsx           # Root layout with slots
└── page.tsx
並列ルートを使ったレイアウト(機能スコープの例)

並列ルートを持つ機能の場合:

// app/[feature]/layout.tsx
export default function FeatureLayout({
  children,
  slot1,
  slot2,
}: {
  children: React.ReactNode;
  slot1: React.ReactNode;
  slot2: React.ReactNode;
}) {
  return (
    <div>
      <h1>Feature Page</h1>
      <div className="main">{children}</div>
      <div className="slots">
        <div className="slot1">{slot1}</div>
        <div className="slot2">{slot2}</div>
      </div>
    </div>
  );
}
並列ルートを使ったレイアウト(ルートレベルの例)

アプリ全体の並列ルートの場合:

// app/layout.tsx
export default function RootLayout({
  children,
  sidebar,
  main,
}: {
  children: React.ReactNode;
  sidebar: React.ReactNode;
  main: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <div className="app-layout">
          <aside>{sidebar}</aside>
          <main>{main}</main>
          {children}
        </div>
      </body>
    </html>
  );
}
デフォルトの並列ルート

一致しないルートを処理するか、フォールバック UI を提供するために、default.tsx を作成します:

// Feature-scoped: app/[feature]/@slot1/default.tsx
export default function Default() {
  return null; // Or a default UI
}

// Root-level: app/@sidebar/default.tsx
export default function Default() {
  return <div>Default sidebar content</div>;
}
条件付き並列ルート

実行時の条件に基づいて、並列ルートを条件付きでレンダリングできます:

// app/[feature]/layout.tsx (or any layout with parallel routes)
export default function Layout({
  children,
  analytics,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
}) {
  const showAnalytics = true; // Could be based on user permissions, feature flags, etc.

  return (
    <div>
      <main>{children}</main>
      {showAnalytics && <aside>{analytics}</aside>}
    </div>
  );
}

注: このパターンは、どのレイアウトレベル(ルートまたは機能スコープ)でも使用できます。

インターセプトルート

インターセプトルートを使用すると、現在のページのコンテキストを維持したまま、現在のレイアウト内でルートを読み込めます。

インターセプトルートの規則
  • (.) - 同じレベルのセグメントに一致します
  • (..) - 1 つ上のレベルのセグメントに一致します
  • (..)(..) - 2 つ上のレベルのセグメントに一致します
  • (...) - ルートからのセグメントに一致します
インターセプトルートを使ったモーダルパターン
app/
├── photos/
│   ├── [id]/
│   │   └── page.tsx        # Full photo page
│   └── page.tsx            # Photo gallery
├── @modal/
│   └── (.)photos/
│       └── [id]/
│           └── page.tsx    # Modal photo view
└── layout.tsx
モーダルパターンのレイアウト
// app/layout.tsx
export default function Layout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <div>
      {children}
      {modal}
    </div>
  );
}
モーダルコンポーネント
// app/@modal/(.)photos/[id]/page.tsx
import Modal from '@/components/Modal';
import PhotoView from '@/components/PhotoView';

export default async function PhotoModal({
  params,
}: {
  params: { id: string };
}) {
  const photo = await getPhoto(params.id);

  return (
    <Modal>
      <PhotoView photo={photo} />
    </Modal>
  );
}

// app/@modal/default.tsx
export default function Default() {
  return null;
}
クライアントサイドのモーダルコンポーネント
// components/Modal.tsx
'use client';

import { useRouter } from 'next/navigation';
import { useEffect, useRef } from 'react';

export default function Modal({ children }: { children: React.ReactNode }) {
  const router = useRouter();
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    dialogRef.current?.showModal();
  }, []);

  const handleClose = () => {
    router.back();
  };

  return (
    <dialog ref={dialogRef} onClose={handleClose}>
      <button onClick={handleClose}>Close</button>
      {children}
    </dialog>
  );
}

エラーバウンダリー

基本的なエラーバウンダリー
// app/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}
ネストされたエラーバウンダリー
// app/dashboard/error.tsx
'use client';

export default function DashboardError({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="dashboard-error">
      <h2>Dashboard Error</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Retry</button>
    </div>
  );
}
グローバルエラーバウンダリー
// app/global-error.tsx
'use client';

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html>
      <body>
        <h2>Application Error</h2>
        <p>{error.message}</p>
        <button onClick={reset}>Try again</button>
      </body>
    </html>
  );
}
Not Found バウンダリー
// app/not-found.tsx
import Link from 'next/link';

export default function NotFound() {
  return (
    <div>
      <h2>Page Not Found</h2>
      <p>Could not find requested resource</p>
      <Link href="/">Return Home</Link>
    </div>
  );
}

// Trigger programmatically
import { notFound } from 'next/navigation';

export default async function Page({ params }: { params: { id: string } }) {
  const post = await getPost(params.id);

  if (!post) {
    notFound();
  }

  return <div>{post.title}</div>;
}

ドラフトモード

ドラフトモードを使用すると、ヘッドレス CMS のドラフトコンテンツをプレビューできます。

ドラフトモードの有効化
// app/api/draft/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const secret = searchParams.get('secret');
  const slug = searchParams.get('slug');

  // Check secret
  if (secret !== process.env.DRAFT_SECRET) {
    return Response.json({ message: 'Invalid token' }, { status: 401 });
  }

  // Enable Draft Mode
  const draft = await draftMode();
  draft.enable();

  // Redirect to the path from the fetched post
  redirect(slug || '/');
}
ドラフトモードの無効化
// app/api/draft/disable/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';

export async function GET() {
  const draft = await draftMode();
  draft.disable();
  redirect('/');
}
ページでのドラフトモードの使用
// app/posts/[slug]/page.tsx
import { draftMode } from 'next/headers';

export default async function Post({ params }: { params: { slug: string } }) {
  const draft = await draftMode();
  const isDraft = draft.isEnabled;

  // Fetch draft or published content
  const post = await getPost(params.slug, isDraft);

  return (
    <article>
      {isDraft && (
        <div className="draft-banner">
          <p>Draft Mode Active</p>
          <a href="/api/draft/disable">Exit Draft Mode</a>
        </div>
      )}
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

ストリーミングと Suspense

Suspense を使った基本的なストリーミング
// app/dashboard/page.tsx
import { Suspense } from 'react';

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>

      <Suspense fallback={<StatsSkeleton />}>
        <Stats />
      </Suspense>

      <Suspense fallback={<RecentActivitySkeleton />}>
        <RecentActivity />
      </Suspense>
    </div>
  );
}

async function Stats() {
  const stats = await fetchStats(); // Slow query
  return <div className="stats">{JSON.stringify(stats)}</div>;
}

async function RecentActivity() {
  const activity = await fetchRecentActivity();
  return (
    <ul>
      {activity.map((item) => (
        <li key={item.id}>{item.description}</li>
      ))}
    </ul>
  );
}
ネストされた Suspense バウンダリー
// app/page.tsx
import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      <Header />

      <Suspense fallback={<PageSkeleton />}>
        <MainContent />
      </Suspense>
    </div>
  );
}

async function MainContent() {
  const data = await fetchMainData();

  return (
    <div>
      <h2>{data.title}</h2>

      <Suspense fallback={<CommentsSkeleton />}>
        <Comments postId={data.id} />
      </Suspense>
    </div>
  );
}

async function Comments({ postId }: { postId: string }) {
  const comments = await fetchComments(postId);
  return (
    <ul>
      {comments.map((c) => <li key={c.id}>{c.text}</li>)}
    </ul>
  );
}
loading.tsx を使ったローディング状態
// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="loading-skeleton">
      <div className="skeleton-header" />
      <div className="skeleton-body" />
    </div>
  );
}
ローディング UI を使ったストリーミング
// app/posts/loading.tsx
export default function PostsLoading() {
  return (
    <div>
      {[1, 2, 3].map((i) => (
        <div key={i} className="post-skeleton">
          <div className="skeleton-title" />
          <div className="skeleton-excerpt" />
        </div>
      ))}
    </div>
  );
}

高度なパターン

Server Actions を使った楽観的更新
// app/components/LikeButton.tsx
'use client';

import { useOptimistic } from 'react';
import { likePost } from '@/app/actions';

export default function LikeButton({
  postId,
  initialLikes,
}: {
  postId: string;
  initialLikes: number;
}) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (state, amount: number) => state + amount
  );

  const handleLike = async () => {
    addOptimisticLike(1);
    await likePost(postId);
  };

  return (
    <button onClick={handleLike}>
      Likes: {optimisticLikes}
    </button>
  );
}
フォームによる段階的な機能強化
// app/posts/new/page.tsx
'use client';

import { useFormState, useFormStatus } from 'react-dom';
import { createPost } from '@/app/actions';

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Creating...' : 'Create Post'}
    </button>
  );
}

export default function NewPost() {
  const [state, formAction] = useFormState(createPost, null);

  return (
    <form action={formAction}>
      <input name="title" placeholder="Title" required />
      <textarea name="content" placeholder="Content" required />

      {state?.errors && (
        <div className="errors">
          {Object.entries(state.errors).map(([field, messages]) => (
            <div key={field}>
              {messages.map((msg) => <p key={msg}>{msg}</p>)}
            </div>
          ))}
        </div>
      )}

      <SubmitButton />
    </form>
  );
}

概要

  • ルートハンドラー - HTTP メソッドをエクスポートする route.ts ファイル内の API エンドポイント
  • サーバーアクション - 'use server' ディレクティブを使用したサーバーサイドの変更処理
  • 並列ルート - @folder 構文を使用して複数のページを同時にレンダリングします
  • インターセプトルート - モーダル向けに (.) 構文を使用して、現在のコンテキスト内でルートを読み込みます
  • エラーバウンダリー - error.tsx と global-error.tsx を使用してエラーを処理します
  • ドラフトモード - CMS のドラフトコンテンツをプレビューします
  • ストリーミング - Suspense 境界を使用した段階的なレンダリング
  • Cookie - ルートハンドラーとサーバーアクションで Cookie にアクセスし、設定します
  • 再検証 - revalidatePath と revalidateTag を使用してキャッシュを無効化します

コメント