取り組みの背景:Claude Code × Next.js 15が本番開発を変える理由
Next.js 15のApp Routerは、React Server Components(RSC)を中心とした新しいアーキテクチャによって、Webアプリケーション開発の考え方を大きく変えましました。しかしその一方で、サーバーサイドとクライアントサイドの境界管理、データフェッチングの最適化、型安全性の確保など、習得すべきパターンが増えたのも事実です。
Claude Codeはこの複雑さを劇的に軽減します。App Routerの設計判断をリアルタイムに相談しながらコードを書き進めることができ、単純な補完ツールとは異なる「ペアプログラミング」体験を提供します。ここで扱うのはClaude Codeを駆使してNext.js 15の本番グレードアプリケーションを構築するための、実践的なワークフローと設計パターンを完全に解説します。
対象読者はNext.js中〜上級者で、App RouterとTypeScriptの基礎知識を持つ方を想定しています。
開発環境のセットアップとClaude Code設定
プロジェクト初期化
まず最適な初期構成でプロジェクトを作成します。Claude Codeに以下のプロンプトを与えると、適切な設定ファイル群を一括生成してくれます。
# プロジェクト作成
npx create-next-app@latest my-app \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir \
--import-alias "@/*"
cd my-app
Claude Codeに依頼すべき初期設定:
以下の構成でNext.js 15 App Routerプロジェクトを本番対応にセットアップしてください:
- Drizzle ORM + PostgreSQL(型安全なDB操作)
- Auth.js v5(認証)
- Zod(バリデーション)
- Vitest(ユニットテスト)
- Playwright(E2Eテスト)
- shadcn/ui(UIコンポーネント)
- Biome(ESLint + Prettier代替、高速)
各ライブラリの設定ファイルと、src/ディレクトリ構成を提案してください。
CLAUDE.md の活用
プロジェクトルートに CLAUDE.md を配置することで、Claude Codeにプロジェクト固有のコンテキストを常に提供できます。
# CLAUDE.md
## プロジェクト概要
Next.js 15 App Router + TypeScript + Cloudflare Workers
## アーキテクチャ原則
- Server Componentsをデフォルトとし、インタラクティブ部分のみ'use client'指定
- データフェッチはServer Componentsで行い、クライアントへのデータ転送を最小化
- Server Actionsでフォーム処理・ミューテーションを実装
- Zodで全入力値をバリデーション
## 命名規則
- コンポーネント: PascalCase (UserProfile.tsx)
- ファイル: kebab-case (user-profile.tsx)
- 型: PascalCase with 'Type' suffix (UserType)
- サーバー関数: 動詞始まり (getUser, createPost)
## 禁止事項
- useState/useEffect をデータフェッチに使用しない
- fetch() をクライアントコンポーネントで直接呼ばない
- 機密情報をクライアントコンポーネントに渡さない
App Routerアーキテクチャ:RSCとCSCの設計判断
RSCとCSCを分ける基準
App Routerで最も重要な設計判断は「どのコンポーネントをサーバーにするか」です。Claude Codeに以下のように相談することで、適切な判断を素早く得られます。
このコンポーネントはRSCとCSCどちらにすべきか判断してください:
- ユーザープロフィール表示(DBから取得、インタラクションなし)
- 検索フォーム(入力値に応じてリアルタイム候補表示)
- 商品一覧(初期表示はSSR、フィルタリングはクライアントで)
- いいねボタン(クリックで即時UI更新 + APIミューテーション)
一般的な判断基準をコード例で示します。
// ✅ Server Component(デフォルト)
// src/app/profile/page.tsx
import { db } from '@/lib/db'
import { users } from '@/lib/schema'
import { eq } from 'drizzle-orm'
// データフェッチはサーバーで完結 - クライアントへの転送ゼロ
export default async function ProfilePage ({
params
} : {
params : Promise <{ id : string }>
}) {
const { id } = await params
const user = await db.query.users. findFirst ({
where: eq (users.id, id),
columns: {
id: true ,
name: true ,
email: true ,
createdAt: true ,
// passwordHash は columns で除外 - 自動的にクライアントに渡らない
}
})
if ( ! user) return < div >ユーザーが見つかりません </ div >
return (
< div >
< h1 >{user.name} </ h1 >
< p >{user.email} </ p >
{ /* インタラクティブな部分だけクライアントコンポーネント */ }
< FollowButton userId = {user.id} />
</ div >
)
}
// ✅ Client Component(必要な場合のみ)
// src/components/follow-button.tsx
'use client'
import { useState, useTransition } from 'react'
import { followUser } from '@/app/actions/user'
export function FollowButton ({ userId } : { userId : string }) {
const [ isFollowing , setIsFollowing ] = useState ( false )
const [ isPending , startTransition ] = useTransition ()
const handleFollow = () => {
startTransition ( async () => {
// Server Actionを呼び出し
await followUser (userId)
setIsFollowing ( prev => ! prev)
})
}
return (
< button
onClick = {handleFollow}
disabled = {isPending}
className = {isFollowing ? 'bg-gray-200' : 'bg-blue-500 text-white' }
>
{ isPending ? '処理中...' : isFollowing ? 'フォロー中' : 'フォローする' }
</ button >
)
}
Suspenseを使ったストリーミング設計
Next.js 15では、Suspenseを組み合わせたストリーミングで初期表示を高速化できます。
// src/app/dashboard/page.tsx
import { Suspense } from 'react'
import { UserStats } from '@/components/user-stats'
import { RecentActivity } from '@/components/recent-activity'
import { Recommendations } from '@/components/recommendations'
import { StatsSkeleton, ActivitySkeleton } from '@/components/skeletons'
// 各コンポーネントが独立してストリーミング
// 重いデータフェッチがあっても他のコンポーネントのレンダリングをブロックしない
export default function DashboardPage () {
return (
< div className = "grid grid-cols-3 gap-4" >
{ /* 軽量なデータ - Suspense不要 */ }
< Suspense fallback = {<StatsSkeleton />} >
< UserStats />
</ Suspense >
{ /* 重いデータ - 独立してストリーミング */ }
< Suspense fallback = {<ActivitySkeleton />} >
< RecentActivity />
</ Suspense >
{ /* 最も重いデータ - 他をブロックしない */ }
< Suspense fallback = {<div>おすすめを読み込み中 ...</ div > } >
< Recommendations />
</ Suspense >
</ div >
)
}
Server Actionsの実践的実装パターン
Server Actionsはフォーム処理とデータミューテーションの中核です。Claude Codeを使うと、型安全なServer Actionsを素早く実装できます。
Zodと組み合わせた型安全Server Action
// src/app/actions/post.ts
'use server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { posts } from '@/lib/schema'
import { revalidatePath } from 'next/cache'
// スキーマ定義
const CreatePostSchema = z. object ({
title: z. string (). min ( 1 , 'タイトルは必須です' ). max ( 100 , '100文字以内で入力してください' ),
content: z. string (). min ( 10 , '10文字以上入力してください' ),
published: z. boolean (). default ( false ),
})
type CreatePostInput = z . infer < typeof CreatePostSchema>
// 戻り値の型定義
type ActionResult < T = void > =
| { success : true ; data : T }
| { success : false ; error : string ; fieldErrors ?: Record < string , string []> }
export async function createPost (
formData : FormData
) : Promise < ActionResult <{ id : string }>> {
// 認証チェック
const session = await auth ()
if ( ! session?.user?.id) {
return { success: false , error: '認証が必要です' }
}
// バリデーション
const rawData = {
title: formData. get ( 'title' ),
content: formData. get ( 'content' ),
published: formData. get ( 'published' ) === 'true' ,
}
const result = CreatePostSchema. safeParse (rawData)
if ( ! result.success) {
return {
success: false ,
error: 'バリデーションエラー' ,
fieldErrors: result.error. flatten ().fieldErrors,
}
}
const { title , content , published } = result.data
try {
// DB挿入
const [ post ] = await db. insert (posts). values ({
title,
content,
published,
authorId: session.user.id,
createdAt: new Date (),
updatedAt: new Date (),
}). returning ({ id: posts.id })
// キャッシュ無効化
revalidatePath ( '/posts' )
if (published) revalidatePath ( '/' )
return { success: true , data: { id: post.id } }
} catch (error) {
console. error ( '投稿作成エラー:' , error)
return { success: false , error: 'サーバーエラーが発生しました' }
}
}
useActionStateによる楽観的UI更新
// src/components/create-post-form.tsx
'use client'
import { useActionState, useOptimistic } from 'react'
import { createPost } from '@/app/actions/post'
const initialState = { success: false as const , error: '' }
export function CreatePostForm () {
const [ state , formAction , isPending ] = useActionState (createPost, initialState)
return (
< form action = {formAction} >
< div >
< label htmlFor = "title" > タイトル </ label >
< input
id = "title"
name = "title"
type = "text"
aria - describedby = {state.success === false && state.fieldErrors?.title ? 'title-error' : undefined }
/>
{! state . success && state . fieldErrors ?. title && (
< p id = "title-error" className = "text-red-500 text-sm" >
{ state . fieldErrors . title [0]}
</ p >
)}
</ div >
< div >
< label htmlFor = "content" > 本文 </ label >
< textarea id = "content" name = "content" rows = { 10 } />
{! state . success && state . fieldErrors ?. content && (
< p className = "text-red-500 text-sm" > {state.fieldErrors.content[ 0 ]}</p>
)}
</div>
<label>
<input type="checkbox" name="published" value="true" />
公開する
</label>
{!state.success && state.error && !state.fieldErrors && (
<p className="text-red-500">{state.error}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? '投稿中...' : '投稿する' }
</ button >
</ form >
)
}
データフェッチング戦略:ウォーターフォール回避と並列化
Request Memorizationの活用
Next.js 15では、同一リクエスト内で同じfetch()を複数回呼んでも1回しか実行されません。この特性を理解することでコードをシンプルに保てます。
// src/lib/queries.ts
import { cache } from 'react'
import { db } from '@/lib/db'
import { users, posts } from '@/lib/schema'
import { eq } from 'drizzle-orm'
// React cache()で関数レベルのメモ化
// 同一リクエスト内で何度呼んでも1回のDBクエリ
export const getUser = cache ( async ( id : string ) => {
return db.query.users. findFirst ({
where: eq (users.id, id),
})
})
export const getUserPosts = cache ( async ( userId : string ) => {
return db.query.posts. findMany ({
where: eq (posts.authorId, userId),
orderBy : ( posts , { desc }) => [ desc (posts.createdAt)],
limit: 10 ,
})
})
Promise.allで並列フェッチング
// src/app/user/[id]/page.tsx
import { getUser, getUserPosts } from '@/lib/queries'
import { notFound } from 'next/navigation'
export default async function UserPage ({
params ,
} : {
params : Promise <{ id : string }>
}) {
const { id } = await params
// 並列フェッチング - 直列より大幅に高速
const [ user , posts ] = await Promise . all ([
getUser (id),
getUserPosts (id),
])
if ( ! user) notFound ()
return (
< div >
< h1 >{user.name} </ h1 >
< PostList posts = {posts} />
</ div >
)
}
キャッシュ戦略の設計
// src/app/products/page.tsx
// 10分間キャッシュ(価格は頻繁に変わらない)
export const revalidate = 600
// 動的なユーザー固有データと組み合わせる場合
export const dynamic = 'force-dynamic' // 毎回サーバーレンダリング
// ビルド時に静的生成するパスを指定
export async function generateStaticParams () {
const categories = await getCategories ()
return categories. map ( cat => ({ category: cat.slug }))
}
Auth.js v5による認証実装
セットアップとセッション管理
Auth.js v5はApp Routerに完全対応した認証ライブラリです。Claude Codeを使うと設定ファイルを迅速に生成できます。
// src/lib/auth.ts
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Google from 'next-auth/providers/google'
import { DrizzleAdapter } from '@auth/drizzle-adapter'
import { db } from '@/lib/db'
export const { handlers , auth , signIn , signOut } = NextAuth ({
adapter: DrizzleAdapter (db),
providers: [
GitHub ({
clientId: process.env. GITHUB_CLIENT_ID ! ,
clientSecret: process.env. GITHUB_CLIENT_SECRET ! ,
}),
Google ({
clientId: process.env. GOOGLE_CLIENT_ID ! ,
clientSecret: process.env. GOOGLE_CLIENT_SECRET ! ,
}),
],
session: {
strategy: 'database' ,
},
callbacks: {
// セッションにユーザーIDとロールを追加
session ({ session , user }) {
session.user.id = user.id
session.user.role = user.role ?? 'user'
return session
},
},
})
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/lib/auth'
export const { GET , POST } = handlers
Middlewareによるルート保護
// src/middleware.ts
import { auth } from '@/lib/auth'
import { NextResponse } from 'next/server'
export default auth (( req ) => {
const { pathname } = req.nextUrl
// 認証が必要なルート
const protectedPaths = [ '/dashboard' , '/settings' , '/admin' ]
const isProtected = protectedPaths. some ( path => pathname. startsWith (path))
if (isProtected && ! req.auth) {
const signInUrl = new URL ( '/sign-in' , req.url)
signInUrl.searchParams. set ( 'callbackUrl' , pathname)
return NextResponse. redirect (signInUrl)
}
// 管理者専用ルート
if (pathname. startsWith ( '/admin' ) && req.auth?.user?.role !== 'admin' ) {
return NextResponse. redirect ( new URL ( '/403' , req.url))
}
return NextResponse. next ()
} )
export const config = {
matcher: [ '/((?!api|_next/static|_next/image|favicon.ico).*)' ],
}
テスト戦略:Vitest + React Testing Library + Playwright
Vitestでのユニット・コンポーネントテスト
// src/lib/queries.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { getUser } from './queries'
// Drizzle ORMのモック
vi. mock ( '@/lib/db' , () => ({
db: {
query: {
users: {
findFirst: vi. fn (),
},
},
},
}))
import { db } from '@/lib/db'
describe ( 'getUser' , () => {
beforeEach (() => {
vi. clearAllMocks ()
})
it ( 'ユーザーが存在する場合はデータを返す' , async () => {
const mockUser = { id: '1' , name: 'テストユーザー' , email: 'test@example.com' }
vi. mocked (db.query.users.findFirst). mockResolvedValue (mockUser)
const result = await getUser ( '1' )
expect (result). toEqual (mockUser)
expect (db.query.users.findFirst). toHaveBeenCalledOnce ()
})
it ( 'ユーザーが存在しない場合はundefinedを返す' , async () => {
vi. mocked (db.query.users.findFirst). mockResolvedValue ( undefined )
const result = await getUser ( 'nonexistent' )
expect (result). toBeUndefined ()
})
})
// src/components/follow-button.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { FollowButton } from './follow-button'
// Server Actionのモック
vi. mock ( '@/app/actions/user' , () => ({
followUser: vi. fn (). mockResolvedValue ({ success: true }),
}))
describe ( 'FollowButton' , () => {
it ( 'クリックでフォロー状態に切り替わる' , async () => {
render (< FollowButton userId = "user-1" />)
const button = screen. getByRole ( 'button' , { name: 'フォローする' })
fireEvent. click (button)
// ローディング状態の確認
expect (button). toBeDisabled ()
// フォロー後の状態確認
await waitFor (() => {
expect (screen. getByRole ( 'button' , { name: 'フォロー中' })). toBeInTheDocument ()
})
})
})
Playwrightでのエンドツーエンドテスト
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test. describe ( '認証フロー' , () => {
test ( '未認証ユーザーがダッシュボードにアクセスするとサインインページにリダイレクトされる' , async ({ page }) => {
await page. goto ( '/dashboard' )
// リダイレクト確認
await expect (page). toHaveURL ( / \/ sign-in/ )
await expect (page. locator ( 'h1' )). toContainText ( 'サインイン' )
})
test ( 'サインイン後にダッシュボードにアクセスできる' , async ({ page }) => {
// テスト用認証(環境変数から認証情報を取得)
await page. goto ( '/sign-in' )
await page. fill ( '[name="email"]' , process.env. TEST_USER_EMAIL ! )
await page. fill ( '[name="password"]' , process.env. TEST_USER_PASSWORD ! )
await page. click ( '[type="submit"]' )
// ダッシュボードへのリダイレクト確認
await expect (page). toHaveURL ( '/dashboard' )
await expect (page. locator ( '[data-testid="user-greeting"]' )). toBeVisible ()
})
})
パフォーマンス最適化:PPRとImage最適化
Partial Pre-rendering(PPR)
Next.js 15で安定化したPPRを使うと、静的シェルと動的コンテンツを組み合わせた最速の初期表示が実現できます。
// next.config.ts
const nextConfig = {
experimental: {
ppr: true , // PPRを有効化
},
}
export default nextConfig
// src/app/products/[id]/page.tsx
import { Suspense } from 'react'
import { ProductDetails } from '@/components/product-details'
import { PersonalizedRecommendations } from '@/components/personalized-recommendations'
import { StaticProductInfo } from '@/components/static-product-info'
// 静的部分(PPRで事前レンダリング)
export default function ProductPage ({ params } : { params : Promise <{ id : string }> }) {
return (
< div >
{ /* 静的コンテンツ - 即座にレンダリング */ }
< StaticProductInfo productId = {params} />
{ /* 動的コンテンツ - Suspenseでストリーミング */ }
< Suspense fallback = {<div>在庫情報を読み込み中 ...</ div > } >
< ProductDetails productId = {params} />
</ Suspense >
{ /* パーソナライズドコンテンツ - セッション依存 */ }
< Suspense fallback = {<div>おすすめを読み込み中 ...</ div > } >
< PersonalizedRecommendations productId = {params} />
</ Suspense >
</ div >
)
}
Cloudflare Workers へのデプロイ自動化
Dolice LabsのサイトはすべてCloudflare Workers上で動作しています。Claude Codeを使ったデプロイ自動化の実践的なパターンを紹介します。
OpenNext + Cloudflare Workers設定
// open-next.config.ts
import type { OpenNextConfig } from 'open-next/types/open-next'
const config : OpenNextConfig = {
default: {
override: {
wrapper: 'cloudflare-node' ,
converter: 'edge' ,
// Cloudflare KV をキャッシュに使用
incrementalCache: 'dummy' ,
tagCache: 'dummy' ,
queue: 'dummy' ,
},
},
middleware: {
external: true ,
override: {
wrapper: 'cloudflare-edge' ,
converter: 'edge' ,
},
},
}
export default config
GitHub Actions CI/CDパイプライン
# .github/workflows/deploy.yml
name : Deploy to Cloudflare Workers
on :
push :
branches : [ main ]
pull_request :
branches : [ main ]
jobs :
test :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : actions/setup-node@v4
with :
node-version : '20'
cache : 'npm'
- run : npm ci
- run : npm run test:unit # Vitestでユニットテスト
- run : npm run build # ビルド確認
- run : npm run test:e2e # Playwrightでe2eテスト
env :
TEST_USER_EMAIL : ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD : ${{ secrets.TEST_USER_PASSWORD }}
deploy :
needs : test
if : github.ref == 'refs/heads/main'
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : actions/setup-node@v4
with :
node-version : '20'
cache : 'npm'
- run : npm ci
- run : npm run build
- name : Deploy to Cloudflare Workers
uses : cloudflare/wrangler-action@v3
with :
apiToken : ${{ secrets.CF_API_TOKEN }}
accountId : ${{ secrets.CF_ACCOUNT_ID }}
Claude Codeに以下を依頼すると、デプロイ後の自動検証スクリプトも生成してくれます。
デプロイ後に以下を自動検証するGitHub Actions stepを追加してください:
1. 主要ページ(/、/products、/about)がHTTP 200を返すか
2. サイドマップが正しく生成されているか
3. Core Web Vitalsが閾値(LCP<2.5s, CLS<0.1)を満たすか
まとめ
Claude Code × Next.js 15 App Routerの組み合わせは、本番グレードのWebアプリケーション開発を根本から変えます。
要点を整理すると:
RSC/CSCの設計 : データフェッチはサーバー側に集中し、クライアントへはインタラクションに必要な最小限のデータのみを渡す
Server Actions : ZodとuseActionStateを組み合わせた型安全なフォーム処理・ミューテーションパターンを確立する
データフェッチング : Promise.all による並列化と React cache() によるメモ化でウォーターフォールを回避する
テスト : Vitest(ユニット)+ Playwright(E2E)の二層構造で品質を担保する
デプロイ : GitHub Actions + Cloudflare Workers で自動化する
Claude Codeの真価は、これらのパターンを「知っている」だけでなく、実際のコードに「即座に適用できる」点にあります。CLAUDE.mdでプロジェクト固有のコンテキストを与え、具体的な制約や目標を伝えることで、プロとしての判断力を持ったパートナーとして機能します。
Next.js App Routerの全体的な開発ワークフローをさらに深めたい方には、Claude Code × Node.js/TypeScriptバックエンド開発ガイド もあわせてご覧ください。また、CI/CDパイプラインの自動化についてはClaude Code × HTTP Hooks CI/CDガイド が参考になります。