nextjs-app-router-patterns
Next.js 14以降のApp Routerを活用し、Server Components・ストリーミング・パラレルルート・高度なデータフェッチングを習得するスキルです。Next.jsアプリの構築、SSR/SSGの実装、React Server Componentsの最適化を行う際に使用してください。
description の原文を見る
Master Next.js 14+ App Router with Server Components, streaming, parallel routes, and advanced data fetching. Use when building Next.js applications, implementing SSR/SSG, or optimizing React Server Components.
SKILL.md 本文
Next.js App Router パターン
Next.js 14+ App Router アーキテクチャ、Server Components、モダンなフルスタック React 開発のための包括的なパターン集。
このスキルを使用する場合
- App Router を使用した新しい Next.js アプリケーションの構築
- Pages Router から App Router への移行
- Server Components とストリーミングの実装
- 並列ルートと割り込みルートの設定
- データフェッチングとキャッシングの最適化
- Server Actions を使用したフルスタック機能の構築
コアコンセプト
1. レンダリングモード
| モード | 実行場所 | 使用する場合 |
|---|---|---|
| Server Components | サーバーのみ | データフェッチング、大量計算、シークレット |
| Client Components | ブラウザ | インタラクティブ性、フック、ブラウザAPI |
| Static | ビルド時 | ほとんど変わらないコンテンツ |
| Dynamic | リクエスト時 | パーソナライズされたリアルタイムデータ |
| Streaming | プログレッシブ | 大規模ページ、低速データソース |
2. ファイル規約
app/
├── layout.tsx # 共有UI ラッパー
├── page.tsx # ルートUI
├── loading.tsx # ローディングUI (Suspense)
├── error.tsx # エラーバウンダリ
├── not-found.tsx # 404 UI
├── route.ts # APIエンドポイント
├── template.tsx # 再マウント済みレイアウト
├── default.tsx # 並列ルートフォールバック
└── opengraph-image.tsx # OG画像生成
クイックスタート
// app/layout.tsx
import { Inter } from 'next/font/google'
import { Providers } from './providers'
const inter = Inter({ subsets: ['latin'] })
export const metadata = {
title: { default: 'My App', template: '%s | My App' },
description: 'Built with Next.js App Router',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
)
}
// app/page.tsx - デフォルトでServer Component
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 }, // ISR: 1時間ごとに再検証
})
return res.json()
}
export default async function HomePage() {
const products = await getProducts()
return (
<main>
<h1>Products</h1>
<ProductGrid products={products} />
</main>
)
}
パターン
パターン 1: データフェッチング付き Server Components
// app/products/page.tsx
import { Suspense } from 'react'
import { ProductList, ProductListSkeleton } from '@/components/products'
import { FilterSidebar } from '@/components/filters'
interface SearchParams {
category?: string
sort?: 'price' | 'name' | 'date'
page?: string
}
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const params = await searchParams
return (
<div className="flex gap-8">
<FilterSidebar />
<Suspense
key={JSON.stringify(params)}
fallback={<ProductListSkeleton />}
>
<ProductList
category={params.category}
sort={params.sort}
page={Number(params.page) || 1}
/>
</Suspense>
</div>
)
}
// components/products/ProductList.tsx - Server Component
async function getProducts(filters: ProductFilters) {
const res = await fetch(
`${process.env.API_URL}/products?${new URLSearchParams(filters)}`,
{ next: { tags: ['products'] } }
)
if (!res.ok) throw new Error('Failed to fetch products')
return res.json()
}
export async function ProductList({ category, sort, page }: ProductFilters) {
const { products, totalPages } = await getProducts({ category, sort, page })
return (
<div>
<div className="grid grid-cols-3 gap-4">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
<Pagination currentPage={page} totalPages={totalPages} />
</div>
)
}
パターン 2: 'use client' を使用した Client Components
// components/products/AddToCartButton.tsx
'use client'
import { useState, useTransition } from 'react'
import { addToCart } from '@/app/actions/cart'
export function AddToCartButton({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
const handleClick = () => {
setError(null)
startTransition(async () => {
const result = await addToCart(productId)
if (result.error) {
setError(result.error)
}
})
}
return (
<div>
<button
onClick={handleClick}
disabled={isPending}
className="btn-primary"
>
{isPending ? 'Adding...' : 'Add to Cart'}
</button>
{error && <p className="text-red-500 text-sm">{error}</p>}
</div>
)
}
パターン 3: Server Actions
// app/actions/cart.ts
"use server";
import { revalidateTag } from "next/cache";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export async function addToCart(productId: string) {
const cookieStore = await cookies();
const sessionId = cookieStore.get("session")?.value;
if (!sessionId) {
redirect("/login");
}
try {
await db.cart.upsert({
where: { sessionId_productId: { sessionId, productId } },
update: { quantity: { increment: 1 } },
create: { sessionId, productId, quantity: 1 },
});
revalidateTag("cart");
return { success: true };
} catch (error) {
return { error: "Failed to add item to cart" };
}
}
export async function checkout(formData: FormData) {
const address = formData.get("address") as string;
const payment = formData.get("payment") as string;
// バリデーション
if (!address || !payment) {
return { error: "Missing required fields" };
}
// 注文処理
const order = await processOrder({ address, payment });
// 確認ページにリダイレクト
redirect(`/orders/${order.id}/confirmation`);
}
パターン 4: 並列ルート
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div className="dashboard-grid">
<main>{children}</main>
<aside className="analytics-panel">{analytics}</aside>
<aside className="team-panel">{team}</aside>
</div>
)
}
// app/dashboard/@analytics/page.tsx
export default async function AnalyticsSlot() {
const stats = await getAnalytics()
return <AnalyticsChart data={stats} />
}
// app/dashboard/@analytics/loading.tsx
export default function AnalyticsLoading() {
return <ChartSkeleton />
}
// app/dashboard/@team/page.tsx
export default async function TeamSlot() {
const members = await getTeamMembers()
return <TeamList members={members} />
}
パターン 5: 割り込みルート (モーダルパターン)
// フォト モーダルのファイル構成
// app/
// ├── @modal/
// │ ├── (.)photos/[id]/page.tsx # 割り込み
// │ └── default.tsx
// ├── photos/
// │ └── [id]/page.tsx # 全ページ
// └── layout.tsx
// app/@modal/(.)photos/[id]/page.tsx
import { Modal } from '@/components/Modal'
import { PhotoDetail } from '@/components/PhotoDetail'
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const photo = await getPhoto(id)
return (
<Modal>
<PhotoDetail photo={photo} />
</Modal>
)
}
// app/photos/[id]/page.tsx - 全ページ版
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const photo = await getPhoto(id)
return (
<div className="photo-page">
<PhotoDetail photo={photo} />
<RelatedPhotos photoId={id} />
</div>
)
}
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
)
}
パターン 6: Suspense によるストリーミング
// app/product/[id]/page.tsx
import { Suspense } from 'react'
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
// このデータが最初に読み込まれます (ブロッキング)
const product = await getProduct(id)
return (
<div>
{/* 即座にレンダリング */}
<ProductHeader product={product} />
{/* レビューをストリーム */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={id} />
</Suspense>
{/* 推奨商品をストリーム */}
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations productId={id} />
</Suspense>
</div>
)
}
// これらのコンポーネントは独自のデータをフェッチします
async function Reviews({ productId }: { productId: string }) {
const reviews = await getReviews(productId) // 低速API
return <ReviewList reviews={reviews} />
}
async function Recommendations({ productId }: { productId: string }) {
const products = await getRecommendations(productId) // ML ベース、低速
return <ProductCarousel products={products} />
}
パターン 7: ルートハンドラー (API ルート)
// app/api/products/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const category = searchParams.get("category");
const products = await db.product.findMany({
where: category ? { category } : undefined,
take: 20,
});
return NextResponse.json(products);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const product = await db.product.create({
data: body,
});
return NextResponse.json(product, { status: 201 });
}
// app/api/products/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const product = await db.product.findUnique({ where: { id } });
if (!product) {
return NextResponse.json({ error: "Product not found" }, { status: 404 });
}
return NextResponse.json(product);
}
パターン 8: メタデータと SEO
// app/products/[slug]/page.tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
type Props = {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const product = await getProduct(slug)
if (!product) return {}
return {
title: product.name,
description: product.description,
openGraph: {
title: product.name,
description: product.description,
images: [{ url: product.image, width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title: product.name,
description: product.description,
images: [product.image],
},
}
}
export async function generateStaticParams() {
const products = await db.product.findMany({ select: { slug: true } })
return products.map((p) => ({ slug: p.slug }))
}
export default async function ProductPage({ params }: Props) {
const { slug } = await params
const product = await getProduct(slug)
if (!product) notFound()
return <ProductDetail product={product} />
}
キャッシング戦略
データキャッシュ
// キャッシュなし (常に新鮮)
fetch(url, { cache: "no-store" });
// 永久キャッシュ (static)
fetch(url, { cache: "force-cache" });
// ISR - 60秒後に再検証
fetch(url, { next: { revalidate: 60 } });
// タグベースの無効化
fetch(url, { next: { tags: ["products"] } });
// Server Action 経由で無効化
("use server");
import { revalidateTag, revalidatePath } from "next/cache";
export async function updateProduct(id: string, data: ProductData) {
await db.product.update({ where: { id }, data });
revalidateTag("products");
revalidatePath("/products");
}
ベストプラクティス
すべきこと
- Server Components から開始 - 必要な場合のみ 'use client' を追加
- データフェッチングをコロケーション - それが使われている場所でフェッチ
- Suspense 境界を使用 - 低速データのストリーミングを有効化
- 並列ルートを活用 - 独立したローディング状態
- Server Actions を使用 - プログレッシブエンハンスメント付きのミューテーション
すべきでないこと
- シリアライズ可能なデータを渡さない - Server → Client 境界の制限
- Server Components でフックを使用しない - useState、useEffect なし
- Client Components でフェッチしない - Server Components または React Query を使用
- レイアウトを過度にネストしない - 各レイアウトはコンポーネントツリーに追加される
- ローディング状態を無視しない - 常に loading.tsx または Suspense を提供
ライセンス: MIT(寛容ライセンスのため全文を引用しています) · 原本リポジトリ
詳細情報
- 作者
- wshobson
- リポジトリ
- wshobson/agents
- ライセンス
- MIT
- 最終更新
- 不明
Source: https://github.com/wshobson/agents / ライセンス: MIT
関連スキル
doubt-driven-development
重要な判断はすべて、本番環境への展開前に新しい視点から対抗的レビューを実施します。速度より正確性が重要な場合、不慣れなコードを扱う場合、本番環境・セキュリティに関わるロジック・取り消し不可の操作など影響度が高い場合、または後でバグを修正するよりも今検証する方が効率的な場合に活用してください。
apprun-skills
TypeScriptを使用したAppRunアプリケーションのMVU設計に関する総合的なガイダンスが得られます。コンポーネントパターン、イベントハンドリング、状態管理(非同期ジェネレータを含む)、パラメータと保護機能を備えたルーティング・ナビゲーション、vistestを使用したテストに対応しています。AppRunコンポーネントの設計・レビュー、ルートの配線、状態フローの管理、AppRunテストの作成時に活用してください。
desloppify
コードベースのヘルスチェックと技術負債の追跡ツールです。コード品質、技術負債、デッドコード、大規模ファイル、ゴッドクラス、重複関数、コードスメル、命名規則の問題、インポートサイクル、結合度の問題についてユーザーが質問した場合に使用してください。また、ヘルススコアの確認、次の改善項目の提案、クリーンアップ計画の作成をリクエストされた際にも対応します。29言語に対応しています。
debugging-and-error-recovery
テストが失敗したり、ビルドが壊れたり、動作が期待と異なったり、予期しないエラーが発生したりした場合に、体系的な根本原因デバッグをガイドします。推測ではなく、根本原因を見つけて修正するための体系的なアプローチが必要な場合に使用してください。
test-driven-development
テスト駆動開発により実装を進めます。ロジックの実装、バグの修正、動作の変更など、あらゆる場面で活用できます。コードが正常に動作することを証明する必要がある場合、バグ報告を受けた場合、既存機能を修正する予定がある場合に使用してください。
incremental-implementation
変更を段階的に実施します。複数のファイルに影響する機能や変更を実装する場合に使用してください。大量のコードを一度に書こうとしている場合や、タスクが一度では完結できないほど大きい場合に活用します。