nextjs-advanced-routing について
Next.js App Routerで高度なルーティング機能を構築する開発者向けのコーディングスキルです。Route Handlers、Server Actions、並列・インターセプトルート、エラー処理、ドラフトモード、Suspenseによるストリーミングを扱います。
主な機能
- 01Route Handlersの作成
- 02Server Actionsの実装
- 03並列ルートとインターセプトルートの構築
- 04エラーとドラフトモードの処理
- 05Suspenseによるコンテンツのストリーミング
- 06TypeScriptの`any`を回避
活用シーン
- 01App RouterプロジェクトへのAPIエンドポイント追加
- 02フォーム送信とサーバー側の変更処理
- 03モーダルやドロワーのルーティング構築
- 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、フォーム送信):
- 'use server'ディレクティブを含むサーバーアクションを別のファイルに作成します
- クライアントコンポーネントでインポートして使用します
✅ 正しいパターン:
// 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 }を返すサンプルコードがあっても、そのパターンをコピーしないでください。これはアンチパターンです。必ず次の手順に従ってください。
- 入力をチェック/検証します
- 検証に失敗した場合はエラーをスローします(エラーオブジェクトは返しません)
- リクエストを処理します
- 何も返しません(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
}
これにより、次のことが実現できます。
- ✅ TypeScriptのチェックに合格します(voidを返します)
- ✅ 処理前にすべての入力を検証します
- ✅ 検証に失敗した場合はエラーをスローします(データベースへの保存を防ぎます)
- ✅ エラーオブジェクトを返しません(返すとフォームアクションの型付けに問題が生じます)
クライアントコンポーネントでのサーバーアクション
// 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
判断基準
-
要件を分析する - 具体的な機能名やページ名を探します
- 特定の名詞や機能への言及がありますか? →
app/[that-feature]/の下に作成します - アプリ全体に関する説明ですか? → ルートにするか、新しいルートにするか判断します
- 特定の名詞や機能への言及がありますか? →
-
URL 構造を検討する - どの URL に配置すべきですか?
/featureパス →app/feature/@slots/を使用します- ルート
/パス →app/@slots/を使用します - ネストされた
/parent/feature→app/parent/feature/@slots/を使用します
-
スコープへの影響を考える - アプリのどの範囲に影響しますか?
- 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を使用してキャッシュを無効化します