nextjs-app-router-expert について
Next.js 14/15のApp Routerを使ったアプリの開発とデバッグを支援するスキルです。ルーティング、React Server Components、データ取得、キャッシュ、Server Actions、ミドルウェア、パフォーマンスを扱います。
主な機能
- 01ファイルベースと高度なルーティング
- 02React Server Componentsとストリーミング
- 03データ取得とキャッシュ戦略
- 04Server Actionsによるフォーム更新
- 05認証やリダイレクト用ミドルウェア
- 06レンダリングとバンドルの最適化
活用シーン
- 01App Routerプロジェクトの開始
- 02Pages Routerからの移行
- 03モーダルや並列ルートの構築
- 04hydrationやRSCの問題の調査
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ curiositech/ some_claude_skills --skill nextjs-app-router-expertインストール後、Claude Code で/nextjs-app-router-expertを入力して読み込まれたか確認します。
同梱ファイル.claude-plugin/plugin.json
詳細
Next.js App Router エキスパート
概要
Next.js 14/15 の App Router アーキテクチャ、React Server Components(RSC)、Server Actions、最新のフルスタック React 開発に精通しています。ルーティングパターン、データ取得戦略、キャッシュ、ストリーミング、デプロイの最適化を専門としています。
使用する場面
- App Router を使用して新しい Next.js プロジェクトを開始するとき
- Pages Router から App Router に移行するとき
- 複雑なルーティングパターン(並列ルート、インターセプトルート)を実装するとき
- RSC とキャッシュを使ってデータ取得を最適化するとき
- ミューテーション用に Server Actions を設定するとき
- 認証やリダイレクト用にミドルウェアを設定するとき
- ハイドレーションエラーや RSC の問題をデバッグするとき
- Vercel、Cloudflare、またはセルフホスト環境にデプロイするとき
機能
ルーティングアーキテクチャ
app/ディレクトリを使ったファイルベースのルーティング- 動的ルート(
[slug]、[...catchAll]、[[...optional]]) - 整理用のルートグループ
(group) - 並列ルート
@modal、@sidebar - インターセプトルート
(.)、(..)、(..)(..) - ルートセグメントごとのローディング境界とエラー境界
React Server Components
- Server コンポーネントと Client コンポーネントの境界
'use client'ディレクティブの配置- コンポジションパターン(Server が Client をラップする構成)
- Suspense 境界を使ったストリーミング
- プログレッシブレンダリング戦略
データ取得
- 自動重複排除機能を備えた
fetch() - キャッシュ戦略(
force-cache、no-store、revalidate) - 静的生成用の
generateStaticParams() - インクリメンタル静的再生成(ISR)
revalidatePath()/revalidateTag()を使ったオンデマンド再検証
Server Actions
'use server'を使ったフォームのミューテーションuseOptimisticを使った楽観的更新- プログレッシブエンハンスメント(JavaScript がなくても動作)
- エラー処理とバリデーション
- ミューテーション後のリダイレクト
Middleware と Edge
- 認証、リダイレクト、リライト用の
middleware.ts - Edge Runtime と Node.js Runtime
- 位置情報と条件付きルーティング
- A/B テストとフィーチャーフラグ
パフォーマンスの最適化
next/imageを使った画像の最適化next/fontを使ったフォントの最適化- スクリプトの読み込み戦略
- バンドル分析とコード分割
- 部分的なプリレンダリング(PPR)
依存関係
以下と相性よく動作します:
react-performance-optimizer- React 固有のパフォーマンスパターンvercel-deployment- Vercel のデプロイ設定cloudflare-worker-dev- Edge へのデプロイパターンpostgresql-optimization- RSC 用のデータベースクエリ
例
基本的なルート構成
app/
├── layout.tsx # Root layout (required)
├── page.tsx # Home page (/)
├── loading.tsx # Loading UI
├── error.tsx # Error boundary
├── not-found.tsx # 404 page
├── blog/
│ ├── page.tsx # /blog
│ └── [slug]/
│ ├── page.tsx # /blog/:slug
│ └── loading.tsx # Per-route loading
└── (auth)/ # Route group (no URL impact)
├── login/
│ └── page.tsx # /login
└── register/
└── page.tsx # /register
データを取得する Server コンポーネント
// app/posts/page.tsx
import { Suspense } from 'react';
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 }, // ISR: revalidate every hour
});
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<main>
<h1>Blog Posts</h1>
<Suspense fallback={<PostsSkeleton />}>
<PostList posts={posts} />
</Suspense>
</main>
);
}
Server Action フォーム
// app/contact/page.tsx
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
async function submitContact(formData: FormData) {
'use server';
const email = formData.get('email') as string;
const message = formData.get('message') as string;
// Validate
if (!email || !message) {
throw new Error('Email and message required');
}
// Save to database
await db.contacts.create({ email, message });
// Revalidate and redirect
revalidatePath('/contact');
redirect('/contact/success');
}
export default function ContactPage() {
return (
<form action={submitContact}>
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
);
}
並列ルート(モーダルパターン)
app/
├── layout.tsx
├── page.tsx
├── @modal/
│ ├── default.tsx # Empty state when no modal
│ └── (.)photo/[id]/
│ └── page.tsx # Intercept /photo/[id] as modal
└── photo/[id]/
└── page.tsx # Full page when direct navigation
// app/layout.tsx
export default function Layout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<>
{children}
{modal}
</>
);
}
認証用ミドルウェア
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token');
const isAuthPage = request.nextUrl.pathname.startsWith('/login');
const isProtectedPage = request.nextUrl.pathname.startsWith('/dashboard');
// Redirect authenticated users away from login
if (isAuthPage && token) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// Redirect unauthenticated users to login
if (isProtectedPage && !token) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/login'],
};
動的パラメーターを使った静的生成
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return posts.map((post: { slug: string }) => ({
slug: post.slug,
}));
}
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return {
title: post?.title ?? 'Post Not Found',
description: post?.excerpt,
};
}
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
{/* NOTE: Always sanitize HTML content with DOMPurify before rendering */}
<div>{post.content}</div>
</article>
);
}
Suspense を使ったストリーミング
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div className="grid grid-cols-2 gap-4">
{/* These load in parallel and stream in as ready */}
<Suspense fallback={<CardSkeleton />}>
<RevenueCard />
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<UsersCard />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
</div>
);
}
// Each component fetches its own data
async function RevenueCard() {
const revenue = await getRevenue(); // Server-side fetch
return <Card title="Revenue" value={revenue} />;
}
ベストプラクティス
- デフォルトでは Server コンポーネントを使う - インタラクティブ性に必要な場合のみ
'use client'を追加します - データ取得を近くに配置する - データが必要なコンポーネント内でデータを取得します
- Suspense 境界を使う - ストリーミングのために非同期コンポーネントをラップします
- キャッシュを活用する - 効率的にキャッシュするため、
revalidateとタグを使います - プログレッシブエンハンスメント - Server Actions は JavaScript がなくても動作します
- 整理にはルートグループを使う - URL に影響を与えず整理するため、
(folder)を使います - セグメントごとにエラー境界を設ける - 重要なルートに
error.tsxを追加します - Metadata API - 動的な SEO には
generateMetadataを使います - ユーザーコンテンツをサニタイズする - HTML をレンダリングする際は、必ず DOMPurify などを使います
よくある落とし穴
- ハイドレーションの不一致 - Server と Client のレンダリングの違い(日付、ランダム値)
- 'use client' の使いすぎ - コンポーネントツリーの上位に Client 境界を置きすぎる
- ウォーターフォール型のデータ取得 - 独立したデータ取得を並列化していない
- ローディング状態の不足 -
loading.tsxや Suspense 境界を忘れる - 古いデータ - ミューテーション後にキャッシュを無効化していない
- 大きなクライアントバンドル - Client コンポーネントでサーバー専用コードをインポートする
- XSS の脆弱性 - サニタイズされていないユーザー入力の HTML をレンダリングする
コメント
Markdown 対応