Communitygithub.com

Samueladewole/claude-skills

Next.js 14+ App Router をサーバーコンポーネント、ストリーミング、並列ルート、高度なデータフェッチでマスターします。Next.js アプリケーションの構築、SSR/SSG の実装、React サーバーコンポーネントの最適化時に使用します。

claude-skills とは?

このスキルは、Next.js 14+ の App Router アーキテクチャ、サーバーコンポーネント、およびモダンなフルスタック React 開発のための包括的なパターンを提供します。Next.js アプリケーションの新規構築、Pages Router からの移行、サーバーコンポーネントとストリーミングの実装、並列ルートとインターセプトルートのセットアップ、データフェッチとキャッシュの最適化、そしてサーバーアクションを使ったフルスタック機能の構築を支援します。Claude Code や Cursor などの AI エージェントで使用でき、各パターンは具体的なコード例とともに、いつ、なぜ使うべきかを明確に説明しています。これにより、開発者は App Router のベストプラクティスを迅速に適用し、パフォーマンスとユーザー体験を向上させることができます。

対応✓Claude Code~Codex CLI~Cursor
npx skills add https://github.com/Samueladewole/claude-skills/tree/HEAD/nextjs-app-router-patterns

お気に入りのAIに質問する

このエージェントスキルを事前に読み込んだ状態で新しいチャットを開きます。

ドキュメント

Next.js App Router パターン

Next.js 14+ App Router アーキテクチャ、サーバーコンポーネント、モダンなフルスタック React 開発のための包括的なパターン集です。

使用するタイミング

  • App Router を使用した新しい Next.js アプリケーションの構築
  • Pages Router から App Router への移行
  • サーバーコンポーネントとストリーミングの実装
  • 並列ルートとインターセプトルートのセットアップ
  • データフェッチとキャッシュの最適化
  • サーバーアクションを使用したフルスタック機能の構築

コアコンセプト

1. レンダリングモード

モード場所使用すべき状況
サーバーコンポーネントサーバーのみデータフェッチ、重い計算、シークレット情報
クライアントコンポーネントブラウザインタラクション、フック、ブラウザ API
静的ビルド時ほとんど変更されないコンテンツ
動的リクエスト時パーソナライズされたデータやリアルタイムデータ
ストリーミングプログレッシブ大きなページ、遅いデータソース

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 - デフォルトでサーバーコンポーネント
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 3600 }, // ISR: 毎時再検証
  })
  return res.json()
}

export default async function HomePage() {
  const products = await getProducts()

  return (
    <main>
      <h1>Products</h1>
      <ProductGrid products={products} />
    </main>
  )
}

パターン

パターン 1: データフェッチを伴うサーバーコンポーネント

// 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 - サーバーコンポーネント
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' を使用したクライアントコンポーネント

// 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: サーバーアクション

// 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;

  // Validate
  if (!address || !payment) {
    return { error: "Missing required fields" };
  }

  // Process order
  const order = await processOrder({ address, payment });

  // Redirect to confirmation
  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" });

// 永久キャッシュ (静的)
fetch(url, { cache: "force-cache" });

// ISR - 60 秒後に再検証
fetch(url, { next: { revalidate: 60 } });

// タグベースの無効化
fetch(url, { next: { tags: ["products"] } });

// サーバーアクションで無効化
("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");
}

ベストプラクティス

推奨事項

  • サーバーコンポーネントから始める - 必要な場合のみ 'use client' を追加
  • データフェッチをまとめる - データは使う場所で取得
  • Suspense 境界を使う - 遅いデータのためにストリーミングを有効化
  • 並列ルートを活用する - 独立したローディング状態
  • サーバーアクションを使う - プログレッシブエンハンスメントを伴う変更処理

避けるべきこと

  • シリアライズ可能でないデータを渡さない - サーバー→クライアント境界の制限
  • サーバーコンポーネントでフックを使わない - useState, useEffect は使えない
  • クライアントコンポーネントでデータフェッチしない - サーバーコンポーネントか React Query を使用
  • レイアウトを過度にネストしない - 各レイアウトがコンポーネントツリーに追加されます
  • ローディング状態を無視しない - 常に loading.tsx または Suspense を提供

リソース

Individual skills in this repo

This repo contains 5 individual skills — each has its own dedicated page.

関連スキル