- Published on
Next.js 15 + React 19完全ガイド:Server ComponentsからServer Actionsまで2025実践攻略
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに
- 1. React 19のイノベーション
- 2. Next.js 15のコア変更点
- 3. Server Components深掘り
- 4. Server Actions実践
- 5. App Router高度なパターン
- 6. キャッシング戦略の完全攻略
- 7. パフォーマンス最適化
- 8. 実践プロジェクト:フルスタックダッシュボード
- 9. Pages RouterからApp Routerへのマイグレーション
- 10. クイズ
- 11. 参考資料
はじめに
React 19とNext.js 15の組(く)み合(あ)わせは、フロントエンド開発(かいはつ)のパラダイムを根本的(こんぽんてき)に変(か)えています。サーバーコンポーネントがデフォルトになり、サーバーアクションでAPIレイヤーがなくなり、TurbopackがビルドSpeed(すぴーど)を革新(かくしん)し、PPRが静的(せいてき)/動的(どうてき)レンダリングの境界(きょうかい)を取(と)り払(はら)いました。
このガイドは、Pages Router時代(じだい)のNext.jsを使(つか)っていた開発者(かいはつしゃ)、またはReact 18までしか経験(けいけん)していない開発者が最新(さいしん)スタックに移行(いこう)するための実践(じっせん)完全(かんぜん)ガイドです。概念(がいねん)の説明(せつめい)にとどまらず、実際(じっさい)のコードパターンとマイグレーション戦略(せんりゃく)までカバーします。
1. React 19のイノベーション
React 19は2024年(ねん)12月(がつ)に安定版(あんていばん)としてリリースされ、Reactの哲学(てつがく)そのものを進化(しんか)させる大規模(だいきぼ)アップデートです。
1.1 React Compiler(自動メモ化)
React Compiler(以前(いぜん)の名称(めいしょう)React Forget)は、手動(しゅどう)でuseMemo、useCallback、memoを書(か)く必要(ひつよう)をなくします。コンパイラがビルド時(じ)にコンポーネントを分析(ぶんせき)し、自動的(じどうてき)にメモ化(か)を適用(てきよう)します。
Before(React 18):
import { useMemo, useCallback, memo } from 'react'
const ExpensiveList = memo(({ items, onSelect }: Props) => {
const sortedItems = useMemo(() => items.sort((a, b) => a.name.localeCompare(b.name)), [items])
const handleClick = useCallback(
(id: string) => {
onSelect(id)
},
[onSelect]
)
return (
<ul>
{sortedItems.map((item) => (
<li key={item.id} onClick={() => handleClick(item.id)}>
{item.name}
</li>
))}
</ul>
)
})
After(React 19 + Compiler):
// React Compilerが自動的にメモ化を適用
// useMemo、useCallback、memoすべて不要
function ExpensiveList({ items, onSelect }: Props) {
const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name))
return (
<ul>
{sortedItems.map((item) => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
)
}
Next.js 15でReact Compilerを有効(ゆうこう)にするには:
// next.config.js
module.exports = {
experimental: {
reactCompiler: true,
},
}
インストールも必要(ひつよう)です:
npm install babel-plugin-react-compiler
1.2 use() Hook — PromiseとContextの読み取り
React 19のuse()フックは、従来(じゅうらい)のフックとは異(こと)なり、条件文(じょうけんぶん)の中(なか)でも呼(よ)び出(だ)せます。PromiseをSuspenseと連動(れんどう)して直接(ちょくせつ)読(よ)み取(と)り、Contextも新(あたら)しい方法(ほうほう)で消費(しょうひ)します。
import { use, Suspense } from 'react'
// Promiseをuse()で直接読み取り
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise) // Suspense境界で自動的に待機
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)
}
// 条件付きでContextを読み取り
function ThemeButton({ showIcon }: { showIcon: boolean }) {
if (showIcon) {
const theme = use(ThemeContext)
return <Icon color={theme.primary} />
}
return <button>Click me</button>
}
// 使用例
function App() {
const userPromise = fetchUser(userId) // Promise作成
return (
<Suspense fallback={<Loading />}>
<UserProfile userPromise={userPromise} />
</Suspense>
)
}
1.3 Actions:useActionState、useFormStatus、useOptimistic
React 19はフォームと非同期(ひどうき)操作(そうさ)のための専用(せんよう)フックセットを導入(どうにゅう)しました。
useActionState — フォームアクションの状態(じょうたい)を管理(かんり)します:
'use client'
import { useActionState } from 'react'
import { createTodo } from '@/actions/todo'
function TodoForm() {
const [state, formAction, isPending] = useActionState(createTodo, {
message: '',
errors: {},
})
return (
<form action={formAction}>
<input name="title" placeholder="タスクを入力" />
{state.errors?.title && <p className="error">{state.errors.title}</p>}
<button type="submit" disabled={isPending}>
{isPending ? '追加中...' : '追加'}
</button>
{state.message && <p>{state.message}</p>}
</form>
)
}
useFormStatus — 親(おや)フォームの送信状態(じょうたい)を子(こ)コンポーネントから読(よ)み取(と)ります:
'use client'
import { useFormStatus } from 'react-dom'
function SubmitButton() {
const { pending, data, method } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? '送信中...' : '送信する'}
</button>
)
}
useOptimistic — 楽観的(らっかんてき)更新(こうしん)を宣言的(せんげんてき)に処理(しょり)します:
'use client'
import { useOptimistic } from 'react'
function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(currentTodos, newTodo: Todo) => [...currentTodos, newTodo]
)
async function handleAdd(formData: FormData) {
const title = formData.get('title') as string
const tempTodo = { id: crypto.randomUUID(), title, completed: false }
addOptimisticTodo(tempTodo) // 即座にUI更新
await createTodoAction(formData) // サーバーに実際に保存
}
return (
<div>
<form action={handleAdd}>
<input name="title" />
<button type="submit">追加</button>
</form>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</div>
)
}
1.4 Document MetadataとStylesheetサポート
React 19では、title、meta、linkタグをコンポーネントツリーのどこでも宣言(せんげん)できます:
function BlogPost({ post }: { post: Post }) {
return (
<article>
<title>{post.title}</title>
<meta name="description" content={post.summary} />
<link rel="stylesheet" href="/styles/blog.css" precedence="default" />
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
stylesheetのprecedence属性(ぞくせい)でCSSのロード順序(じゅんじょ)を制御(せいぎょ)し、Suspenseと連動(れんどう)してスタイルシートがロードされるまでコンテンツのレンダリングを待機(たいき)させることができます。
1.5 Ref as Prop(forwardRef不要)
React 19では、関数(かんすう)コンポーネントがrefを通常(つうじょう)のpropとして受(う)け取(と)れます:
// Before(React 18)— forwardRefが必要
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
return <input ref={ref} {...props} />
})
// After(React 19)— refをそのままpropとして
function Input({ ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
return <input ref={ref} {...props} />
}
2. Next.js 15のコア変更点
Next.js 15は2024年(ねん)10月(がつ)にリリースされ、React 19を公式(こうしき)サポートする最初(さいしょ)のメジャーフレームワークバージョンです。
2.1 Turbopackの安定化
Turbopackがnext dev --turbopackで安定版(あんていばん)になりました。Rustベースの増分(ぞうぶん)ビルドシステムにより:
- ローカルサーバー起動(きどう):最大(さいだい)76.7%高速(こうそく)
- Fast Refresh:最大96.3%高速
- 初回(しょかい)ルートコンパイル:最大45.8%高速(キャッシュなしの場合)
{
"scripts": {
"dev": "next dev --turbopack",
"build": "next build"
}
}
プロダクションビルド(next build)でのTurbopackサポートはまだ実験的(じっけんてき)で、--turbopackフラグで試(ため)せます。
2.2 非同期Request APIs
Next.js 15での最大(さいだい)の**破壊的変更(はかいてきへんこう)**です。ランタイムのRequest情報(じょうほう)にアクセスするAPIがすべて非同期(ひどうき)になりました:
// Before(Next.js 14)
import { cookies, headers } from 'next/headers'
export default function Page({ params, searchParams }: PageProps) {
const cookieStore = cookies()
const headersList = headers()
const slug = params.slug
const query = searchParams.q
// ...
}
// After(Next.js 15)— すべてawaitが必要
import { cookies, headers } from 'next/headers'
export default async function Page({
params,
searchParams,
}: {
params: Promise<{ slug: string }>
searchParams: Promise<{ q: string }>
}) {
const cookieStore = await cookies()
const headersList = await headers()
const { slug } = await params
const { q } = await searchParams
// ...
}
この変更(へんこう)はサーバーレンダリングの最適化(さいてきか)のためです。Next.jsはリクエストデータの実際(じっさい)の使用時点(じてん)まで遅延(ちえん)できるようになり、静的部分(ぶぶん)は事前(じぜん)にレンダリングし、動的部分(ぶぶん)のみリクエスト時(じ)に処理(しょり)します。
自動マイグレーションコードモッド:
npx @next/codemod@canary next-async-request-api .
2.3 PPR(Partial Pre-Rendering)
PPRは1つのページで静的(せいてき)部分(ぶぶん)と動的(どうてき)部分を同時(どうじ)に処理(しょり)する革新的(かくしんてき)なレンダリング戦略(せんりゃく)です:
// next.config.js
module.exports = {
experimental: {
ppr: 'incremental', // ルートごとに段階的に適用
},
}
// app/product/[id]/page.tsx
import { Suspense } from 'react'
export const experimental_ppr = true // このルートでPPRを有効化
// 静的シェル:ビルド時に事前レンダリング
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const product = await getProduct(id) // ビルド時に取得
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<StaticImage src={product.image} />
{/* 動的部分:リクエスト時にストリーミング */}
<Suspense fallback={<PriceSkeleton />}>
<DynamicPrice productId={id} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<DynamicReviews productId={id} />
</Suspense>
</div>
)
}
// 動的コンポーネント — cookies/headers使用で自動的にdynamic
async function DynamicPrice({ productId }: { productId: string }) {
const cookieStore = await cookies()
const region = cookieStore.get('region')?.value ?? 'US'
const price = await getPrice(productId, region)
return <PriceDisplay price={price} currency={region} />
}
PPRの動作原理(げんり):
- ビルド時(じ)に静的シェル(HTML)を生成(せいせい)
- 動的部分(ぶぶん)をSuspense境界(きょうかい)で表示(ひょうじ)し、フォールバックを含(ふく)める
- リクエストが来(き)たら静的シェルを即座(そくざ)に送信(そうしん)し、動的部分をストリーミング
2.4 next/after API
レスポンスを送信(そうしん)した後(あと)に非同期(ひどうき)作業(さぎょう)を実行(じっこう)できる新(あたら)しいAPIです:
import { after } from 'next/server'
import { log } from '@/lib/logger'
export default async function Page() {
const data = await fetchData()
// レスポンス後に実行 — ユーザーのレスポンス遅延なし
after(() => {
log('page-view', { data: data.id })
})
return <Dashboard data={data} />
}
ロギング、分析(ぶんせき)、キャッシュウォーミングなど、ユーザーのレスポンスに影響(えいきょう)を与(あた)えてはいけない作業(さぎょう)に理想的(りそうてき)です。
2.5 キャッシングデフォルトの変更
Next.js 15での最(もっと)も重要(じゅうよう)な哲学的(てつがくてき)変更(へんこう)です:
| 項目 | Next.js 14 | Next.js 15 |
|---|---|---|
| fetchキャッシュ | force-cache(デフォルトでキャッシュ) | no-store(デフォルトでキャッシュなし) |
| GET Route Handler | キャッシュされる | キャッシュされない |
| Client Router Cache | 5分 | 0秒(ナビゲーション時常に最新) |
// Next.js 15 — 明示的なキャッシュ設定を推奨
const data = await fetch('https://api.example.com/posts', {
cache: 'force-cache', // 明示的にキャッシュを有効化
next: { revalidate: 3600 }, // 1時間後に再検証
})
3. Server Components深掘り
3.1 RSCの動作原理
React Server ComponentsのコアはRSC Payload(別名(べつめい)Flight Protocol)です。サーバーで生成(せいせい)されたこのシリアライゼーション形式(けいしき)は、コンポーネントツリーをクライアントに伝達(でんたつ)します。
レンダリングフローは以下(いか)の通(とお)りです:
- サーバーでRSCツリーをレンダリングしてRSC Payloadを生成
- クライアントコンポーネント用(よう)の参照(さんしょう)(バンドルパス)を含(ふく)める
- クライアントでRSC Payloadを受(う)け取(と)りDOMツリーを構築(こうちく)
- クライアントコンポーネントのみハイドレーション実行(じっこう)
[Server] [Client]
| |
|-- RSC Payload (stream) -->|
| - シリアライズされた |
| サーバーコンポーネント |-- DOM構築
| - クライアント参照 |-- クライアントハイドレーション
| - Suspense境界 |
| |
3.2 サーバー vs クライアントコンポーネントの境界
サーバーコンポーネントでできること:
- DB直接(ちょくせつ)アクセス
- ファイルシステムの読(よ)み取(と)り
- サーバー専用(せんよう)ライブラリの使用(fs、crypto、ORMなど)
- サーバー専用(せんよう)環境変数(かんきょうへんすう)へのアクセス
- バンドルサイズへの影響(えいきょう)なし
クライアントコンポーネントが必要(ひつよう)な場合(ばあい):
- useState、useEffectなどのReactフック
- ブラウザAPI(localStorage、windowなど)
- イベントリスナー(onClick、onChangeなど)
- クラスコンポーネント
- サードパーティライブラリ(ほとんどがクライアント専用)
// app/dashboard/page.tsx(Server Component — デフォルト)
import { db } from '@/lib/db'
import { DashboardChart } from './chart' // Client Component
export default async function DashboardPage() {
// サーバーで直接DB接続 — APIレイヤー不要
const metrics = await db.metrics.findMany({
where: { date: { gte: thirtyDaysAgo() } },
orderBy: { date: 'asc' },
})
// シリアライズ可能なデータのみクライアントコンポーネントに渡す
return (
<div>
<h1>ダッシュボード</h1>
<DashboardChart data={metrics} /> {/* Client Component */}
</div>
)
}
// app/dashboard/chart.tsx(Client Component)
'use client'
import { LineChart, Line, XAxis, YAxis } from 'recharts'
export function DashboardChart({ data }: { data: Metric[] }) {
// クライアントでのみ実行されるインタラクティブチャート
return (
<LineChart width={800} height={400} data={data}>
<XAxis dataKey="date" />
<YAxis />
<Line type="monotone" dataKey="value" stroke="#8884d8" />
</LineChart>
)
}
3.3 Compositionパターン:サーバーがクライアントを包む
サーバーコンポーネントがクライアントコンポーネントの親(おや)になるのがRSCのコアパターンです。逆(ぎゃく)に、クライアントコンポーネント内(ない)でサーバーコンポーネントをimportすると自動的(じどうてき)にクライアントになります。代(か)わりにchildrenパターンを使用(しよう)します:
// app/layout.tsx(Server Component)
import { Sidebar } from './sidebar' // Client Component
import { UserInfo } from './user-info' // Server Component
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex">
{/* サーバーコンポーネントをクライアントコンポーネントのchildrenとして渡す */}
<Sidebar>
<UserInfo /> {/* これによりサーバーコンポーネントとして維持 */}
</Sidebar>
<main>{children}</main>
</div>
)
}
// app/sidebar.tsx(Client Component)
'use client'
import { useState } from 'react'
export function Sidebar({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(true)
return (
<aside className={isOpen ? 'w-64' : 'w-16'}>
<button onClick={() => setIsOpen(!isOpen)}>トグル</button>
{isOpen && children} {/* サーバーでレンダリングされたchildren */}
</aside>
)
}
3.4 DB直接アクセスとAPIレイヤーの削除
サーバーコンポーネントの最(もっと)も強力(きょうりょく)な利点(りてん)の一(ひと)つは、別途(べっと)のAPIエンドポイントなしでデータベースに直接(ちょくせつ)アクセスできることです:
// Before(Pages Router + API Routes)
// 1. pages/api/posts.ts — APIエンドポイント
// 2. lib/fetcher.ts — fetchユーティリティ
// 3. pages/blog.tsx — getServerSideProps + クライアントコンポーネント
// After(App Router + Server Components)
// 1つのファイルで十分
// app/blog/page.tsx
import { prisma } from '@/lib/prisma'
import { cache } from 'react'
// React cacheで同一リクエスト内の重複防止
const getPosts = cache(async () => {
return prisma.post.findMany({
include: { author: true, tags: true },
orderBy: { createdAt: 'desc' },
take: 20,
})
})
export default async function BlogPage() {
const posts = await getPosts()
return (
<div>
<h1>ブログ</h1>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.author.name}</p>
<time>{post.createdAt.toLocaleDateString()}</time>
</article>
))}
</div>
)
}
3.5 バンドルサイズの最適化
サーバーコンポーネントで使用(しよう)するライブラリはクライアントバンドルに含(ふく)まれません:
// サーバーコンポーネント — これらのimportはクライアントバンドルに含まれない
import { marked } from 'marked' // 35KB
import hljs from 'highlight.js' // 180KB
import { parse } from 'yaml' // 25KB
import sanitizeHtml from 'sanitize-html' // 60KB
// 合計300KBのライブラリがクライアントに全く送信されない!
export default async function MarkdownPage({ slug }: { slug: string }) {
const raw = await fs.readFile(`content/${slug}.md`, 'utf-8')
const html = marked(raw, {
highlight: (code, lang) => hljs.highlight(code, { language: lang }).value,
})
const safe = sanitizeHtml(html)
return <div dangerouslySetInnerHTML={{ __html: safe }} />
}
4. Server Actions実践
Server Actionsは'use server'ディレクティブで定義(ていぎ)し、クライアントからサーバー関数(かんすう)を直接(ちょくせつ)呼(よ)び出(だ)すRPCパターンです。
4.1 フォーム処理:useActionState + Progressive Enhancement
// actions/auth.ts
'use server'
import { z } from 'zod'
import { redirect } from 'next/navigation'
import { createSession } from '@/lib/session'
const loginSchema = z.object({
email: z.string().email('有効なメールアドレスを入力してください'),
password: z.string().min(8, 'パスワードは8文字以上必要です'),
})
type LoginState = {
message: string
errors: Record<string, string[]>
}
export async function loginAction(prevState: LoginState, formData: FormData): Promise<LoginState> {
const rawData = {
email: formData.get('email'),
password: formData.get('password'),
}
const validated = loginSchema.safeParse(rawData)
if (!validated.success) {
return {
message: '',
errors: validated.error.flatten().fieldErrors as Record<string, string[]>,
}
}
const user = await authenticateUser(validated.data)
if (!user) {
return {
message: 'メールアドレスまたはパスワードが正しくありません',
errors: {},
}
}
await createSession(user.id)
redirect('/dashboard')
}
// app/login/page.tsx
'use client'
import { useActionState } from 'react'
import { loginAction } from '@/actions/auth'
export default function LoginPage() {
const [state, formAction, isPending] = useActionState(loginAction, {
message: '',
errors: {},
})
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="email">メールアドレス</label>
<input id="email" name="email" type="email" required />
{state.errors?.email && <p className="text-red-500">{state.errors.email[0]}</p>}
</div>
<div>
<label htmlFor="password">パスワード</label>
<input id="password" name="password" type="password" required />
{state.errors?.password && <p className="text-red-500">{state.errors.password[0]}</p>}
</div>
{state.message && <p className="text-red-500">{state.message}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'ログイン中...' : 'ログイン'}
</button>
</form>
)
}
このフォームはProgressive Enhancementをサポートします。JavaScriptが無効(むこう)な環境(かんきょう)でもHTMLフォーム自体(じたい)が動作(どうさ)します。
4.2 楽観的更新の実践
// app/comments/comment-section.tsx
'use client'
import { useOptimistic, useRef } from 'react'
import { addComment } from '@/actions/comments'
type Comment = {
id: string
text: string
author: string
createdAt: string
pending?: boolean
}
export function CommentSection({
postId,
initialComments,
}: {
postId: string
initialComments: Comment[]
}) {
const formRef = useRef<HTMLFormElement>(null)
const [optimisticComments, addOptimisticComment] = useOptimistic(
initialComments,
(state, newComment: Comment) => [...state, newComment]
)
async function handleSubmit(formData: FormData) {
const text = formData.get('text') as string
// 即座にUI反映(楽観的)
addOptimisticComment({
id: `temp-${Date.now()}`,
text,
author: '自分',
createdAt: new Date().toISOString(),
pending: true,
})
formRef.current?.reset()
// サーバーに実際に保存
await addComment(postId, formData)
}
return (
<div>
<ul className="space-y-4">
{optimisticComments.map((comment) => (
<li key={comment.id} className={comment.pending ? 'opacity-50' : ''}>
<strong>{comment.author}</strong>
<p>{comment.text}</p>
{comment.pending && <span>送信中...</span>}
</li>
))}
</ul>
<form ref={formRef} action={handleSubmit}>
<textarea name="text" placeholder="コメントを入力してください" required />
<button type="submit">コメント投稿</button>
</form>
</div>
)
}
4.3 ファイルアップロード
// actions/upload.ts
'use server'
import { writeFile } from 'fs/promises'
import { join } from 'path'
import { revalidatePath } from 'next/cache'
export async function uploadFile(formData: FormData) {
const file = formData.get('file') as File
if (!file || file.size === 0) {
return { error: 'ファイルを選択してください' }
}
// ファイルサイズ制限(5MB)
if (file.size > 5 * 1024 * 1024) {
return { error: 'ファイルサイズは5MB以下にしてください' }
}
// 許可された型を確認
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
if (!allowedTypes.includes(file.type)) {
return { error: 'JPEG、PNG、WebP画像のみアップロード可能です' }
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const uniqueName = `${Date.now()}-${file.name}`
const path = join(process.cwd(), 'public/uploads', uniqueName)
await writeFile(path, buffer)
revalidatePath('/gallery')
return { success: true, path: `/uploads/${uniqueName}` }
}
4.4 revalidatePathとrevalidateTag
// actions/post.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function updatePost(id: string, formData: FormData) {
await db.post.update({
where: { id },
data: {
title: formData.get('title') as string,
content: formData.get('content') as string,
},
})
// 特定パスの再検証
revalidatePath(`/blog/${id}`)
// タグベースの再検証(より細かい制御)
revalidateTag('posts')
revalidateTag(`post-${id}`)
}
// データフェッチ時にタグを指定
async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
next: { tags: [`post-${id}`, 'posts'] },
})
return res.json()
}
4.5 Server Actions vs API Routesの比較
| 機能 | Server Actions | Route Handlers(API Routes) |
|---|---|---|
| 使用パターン | フォーム送信、データ変更 | 外部API、Webhook、サードパーティ連携 |
| Progressive Enhancement | サポート(JS無しで動作) | 非サポート |
| 型安全性 | 関数シグネチャで保証 | 手動での型定義が必要 |
| 呼び出し方式 | form actionまたは直接呼び出し | fetch/HTTPリクエスト |
| キャッシング | 自動revalidation統合 | 手動キャッシュ設定 |
| セキュリティ | 自動CSRF保護 | 手動実装が必要 |
| 用途 | 内部データ変更 | 外部システム連携 |
5. App Router高度なパターン
5.1 Parallel Routes(@slot)
1つのレイアウトで複数(ふくすう)のページを同時(どうじ)にレンダリングします:
app/
dashboard/
@analytics/
page.tsx <- 分析スロット
loading.tsx <- 分析ローディング状態
@team/
page.tsx <- チームスロット
loading.tsx <- チームローディング状態
layout.tsx <- 両スロットを組み合わせ
page.tsx <- メインコンテンツ
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">{children}</div>
<div>{analytics}</div>
<div>{team}</div>
</div>
)
}
各(かく)スロットは独立(どくりつ)してロードされ、1つが遅(おそ)くても残(のこ)りは先(さき)に表示(ひょうじ)されます。
5.2 Intercepting Routes
モーダルパターンで特(とく)に有用(ゆうよう)です。URLを維持(いじ)しながら現在(げんざい)のレイアウト内(ない)で別(べつ)のルートを表示(ひょうじ)します:
app/
feed/
page.tsx <- フィードリスト
@modal/
(..)photo/[id]/
page.tsx <- モーダルで写真表示
default.tsx
layout.tsx
photo/[id]/
page.tsx <- フルページ写真表示(直接アクセス/リロード時)
// app/feed/@modal/(..)photo/[id]/page.tsx
import { Modal } from '@/components/modal'
export default async function PhotoModal({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const photo = await getPhoto(id)
return (
<Modal>
<img src={photo.url} alt={photo.alt} />
<p>{photo.description}</p>
</Modal>
)
}
フィードで写真(しゃしん)をクリックするとモーダルで開(ひら)きますが、URLは/photo/123に変更(へんこう)されます。このURLを直接(ちょくせつ)訪問(ほうもん)またはリロードすると、フルページバージョンがレンダリングされます。
5.3 Route Groups
URL構造(こうぞう)に影響(えいきょう)を与(あた)えずにルートを論理的(ろんりてき)にグループ化(か)します:
app/
(marketing)/ <- URLに含まれない
layout.tsx <- マーケティング専用レイアウト
page.tsx <- / パス
about/
page.tsx <- /about
pricing/
page.tsx <- /pricing
(dashboard)/ <- URLに含まれない
layout.tsx <- ダッシュボード専用レイアウト(サイドバーなど)
dashboard/
page.tsx <- /dashboard
settings/
page.tsx <- /settings
5.4 Loading、Error、Not-Foundバウンダリ
App Routerのファイルコンベンションは自動的(じどうてき)にSuspenseとError Boundaryを設定(せってい)します:
// app/blog/loading.tsx — Suspense fallbackが自動適用
export default function Loading() {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 w-3/4 rounded bg-gray-200" />
<div className="h-4 w-full rounded bg-gray-200" />
<div className="h-4 w-5/6 rounded bg-gray-200" />
</div>
)
}
// app/blog/error.tsx — Error Boundaryが自動適用
'use client' // Errorコンポーネントは必ずClient Component
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="py-10 text-center">
<h2>問題が発生しました</h2>
<p>{error.message}</p>
<button onClick={reset} className="mt-4 bg-blue-500 px-4 py-2 text-white">
もう一度試す
</button>
</div>
)
}
// app/blog/[slug]/not-found.tsx
export default function NotFound() {
return (
<div className="py-20 text-center">
<h2 className="text-2xl font-bold">記事が見つかりません</h2>
<p className="mt-2 text-gray-600">リクエストされた記事は存在しません。</p>
</div>
)
}
5.5 generateMetadataで動的SEO
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
if (!post) {
return { title: 'Post Not Found' }
}
return {
title: post.title,
description: post.summary,
openGraph: {
title: post.title,
description: post.summary,
images: [post.coverImage],
type: 'article',
publishedTime: post.publishedAt,
authors: [post.author.name],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.summary,
images: [post.coverImage],
},
}
}
5.6 Middlewareの活用
// middleware.ts(プロジェクトルート)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// 認証チェック
const token = request.cookies.get('session-token')
if (request.nextUrl.pathname.startsWith('/dashboard') && !token) {
return NextResponse.redirect(new URL('/login', request.url))
}
// 国際化 — Accept-Languageベースのリダイレクト
const locale = request.headers.get('accept-language')?.split(',')[0]?.split('-')[0]
if (request.nextUrl.pathname === '/' && locale === 'ko') {
return NextResponse.rewrite(new URL('/ko', request.url))
}
// セキュリティヘッダーの追加
const response = NextResponse.next()
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('X-Content-Type-Options', 'nosniff')
return response
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
6. キャッシング戦略の完全攻略
Next.jsのキャッシングは4つのレイヤーで構成(こうせい)されており、それぞれの役割(やくわり)を理解(りかい)することが重要(じゅうよう)です。
6.1 4つのキャッシングレイヤー
1. Request Memoization(リクエストメモ化)
同(おな)じレンダリングサイクル内(ない)で同一(どういつ)のfetchリクエストを自動(じどう)的に重複排除(じゅうふくはいじょ)します:
// 同一レンダリング内の2つのコンポーネントで同じ呼び出し -> 実際のリクエストは1回のみ
async function Header() {
const user = await fetch('/api/user') // リクエスト1
return <h1>{user.name}</h1>
}
async function Sidebar() {
const user = await fetch('/api/user') // メモ化 — 実際のリクエストなし
return <nav>{user.role}</nav>
}
2. Data Cache(データキャッシュ)
fetchレスポンスをサーバーに永続的(えいぞくてき)に保存(ほぞん)します(Next.js 15ではデフォルト無効(むこう)):
// 明示的なキャッシュ設定
const data = await fetch('https://api.example.com/data', {
cache: 'force-cache', // 永続キャッシュ
next: { revalidate: 3600 }, // 1時間後に再検証
next: { tags: ['products'] }, // タグベースの再検証
})
3. Full Route Cache(フルルートキャッシュ)
ビルド時(じ)に生成(せいせい)された静的ルートのHTMLとRSC Payloadをキャッシュします:
// このページはビルド時にキャッシュされる(静的ルート)
export default async function AboutPage() {
return <div>会社概要</div>
}
// generateStaticParamsで動的パスも静的生成
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map((post) => ({ slug: post.slug }))
}
4. Router Cache(ルーターキャッシュ)
クライアントで訪問(ほうもん)したルートのRSC Payloadをメモリにキャッシュします。Next.js 15ではデフォルト0秒(びょう)(常(つね)に最新データ):
// next.config.jsでRouter Cacheの設定が可能
module.exports = {
experimental: {
staleTimes: {
dynamic: 30, // 動的ルート:30秒
static: 180, // 静的ルート:180秒
},
},
}
6.2 cacheLifeとcacheTag(Next.js 15)
以前(いぜん)のunstable_cacheを置(お)き換(か)える新(あたら)しいキャッシングAPIです:
// next.config.js
module.exports = {
experimental: {
dynamicIO: true,
cacheLife: {
blog: {
stale: 300, // 5分間キャッシュを使用
revalidate: 900, // 15分ごとにバックグラウンド再検証
expire: 86400, // 24時間後に期限切れ
},
frequent: {
stale: 0,
revalidate: 60,
expire: 300,
},
},
},
}
import { cacheLife, cacheTag } from 'next/cache'
async function getProducts() {
'use cache'
cacheLife('blog')
cacheTag('products')
return db.product.findMany()
}
6.3 レンダリング戦略の比較表
| 戦略 | ビルド時 | リクエスト時 | 再検証 | ユースケース |
|---|---|---|---|---|
| SSG(Static) | HTML生成 | キャッシュから提供 | リビルド時 | ブログ、ドキュメント |
| ISR | HTML生成 | キャッシュ + バックグラウンド更新 | 時間/タグベース | 商品リスト、ニュース |
| SSR | — | 毎リクエストレンダリング | なし | パーソナライズページ |
| PPR | 静的シェル生成 | 動的部分のみレンダリング | 混合 | 商品詳細(価格+説明) |
6.4 実践:ECサイト商品ページのキャッシング戦略
// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { unstable_cache } from 'next/cache'
// 商品基本情報:あまり変わらないのでISR(1時間)
const getProductInfo = unstable_cache(
async (id: string) => {
return db.product.findUnique({
where: { id },
select: { name: true, description: true, images: true },
})
},
['product-info'],
{ revalidate: 3600, tags: ['products'] }
)
// 価格/在庫:頻繁に変わるので短いキャッシュ(1分)
const getProductPricing = unstable_cache(
async (id: string) => {
return db.product.findUnique({
where: { id },
select: { price: true, stock: true, discount: true },
})
},
['product-pricing'],
{ revalidate: 60, tags: ['pricing'] }
)
// レビュー:ユーザー別データを含むため動的
async function getProductReviews(id: string) {
return db.review.findMany({
where: { productId: id },
orderBy: { createdAt: 'desc' },
take: 10,
})
}
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const product = await getProductInfo(id)
return (
<div>
<h1>{product?.name}</h1>
<p>{product?.description}</p>
<Suspense fallback={<div>価格ロード中...</div>}>
<PriceSection productId={id} />
</Suspense>
<Suspense fallback={<div>レビューロード中...</div>}>
<ReviewSection productId={id} />
</Suspense>
</div>
)
}
async function PriceSection({ productId }: { productId: string }) {
const pricing = await getProductPricing(productId)
return (
<div>
<span className="text-2xl font-bold">{pricing?.price}円</span>
{pricing?.discount && <span className="text-red-500">{pricing.discount}%オフ</span>}
<p>{pricing?.stock ? `在庫:${pricing.stock}個` : '在庫切れ'}</p>
</div>
)
}
async function ReviewSection({ productId }: { productId: string }) {
const reviews = await getProductReviews(productId)
return (
<div>
<h2>レビュー({reviews.length})</h2>
{reviews.map((review) => (
<div key={review.id}>
<strong>{review.author}</strong>
<p>{review.content}</p>
</div>
))}
</div>
)
}
7. パフォーマンス最適化
7.1 Turbopack vs Webpackベンチマーク
Next.js 15での実際(じっさい)のプロジェクト基準(きじゅん)の比較(ひかく):
| 測定項目 | Webpack | Turbopack | 改善率 |
|---|---|---|---|
| Cold Start(dev) | 8.2秒 | 1.9秒 | 76.7% |
| Hot Module Replacement | 520ms | 19ms | 96.3% |
| ルートコンパイル(初回アクセス) | 1.8秒 | 0.97秒 | 45.8% |
| メモリ使用量(2000+モジュール) | 1.2GB | 650MB | 45.8% |
7.2 next/image、next/font、next/script
// Image最適化
import Image from 'next/image'
function ProductCard({ product }: { product: Product }) {
return (
<Image
src={product.image}
alt={product.name}
width={400}
height={300}
placeholder="blur"
blurDataURL={product.blurHash}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority={false} // スクロール下の画像はlazy load
/>
)
}
// Font最適化 — レイアウトシフト防止
import { Inter, Noto_Sans_JP } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
})
const notoSansJP = Noto_Sans_JP({
subsets: ['latin'],
weight: ['400', '700'],
display: 'swap',
variable: '--font-noto',
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja" className={`${inter.variable} ${notoSansJP.variable}`}>
<body>{children}</body>
</html>
)
}
// Script最適化
import Script from 'next/script'
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
{children}
{/* afterInteractive:ページハイドレーション後にロード(デフォルト) */}
<Script src="https://analytics.example.com/script.js" strategy="afterInteractive" />
{/* lazyOnload:ブラウザアイドル時にロード */}
<Script src="https://chatbot.example.com/widget.js" strategy="lazyOnload" />
{/* worker:Web Workerで実行(実験的) */}
<Script src="https://heavy-lib.example.com/main.js" strategy="worker" />
</>
)
}
7.3 Bundle Analyzer + Tree Shaking
npm install @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({
// 既存の設定
})
ANALYZE=true npm run build
Tree Shaking最適化(さいてきか)のヒント:
// Bad — ライブラリ全体をimport
import _ from 'lodash'
const sorted = _.sortBy(items, 'name')
// Good — 必要な関数のみimport
import sortBy from 'lodash/sortBy'
const sorted = sortBy(items, 'name')
// Best — Server Componentsで使用(バンドルに含まれない)
// Server Componentなら全体importでも問題なし
import _ from 'lodash'
7.4 Dynamic ImportsとLazy Loading
import dynamic from 'next/dynamic'
// クライアント専用コンポーネントのlazy load
const DynamicChart = dynamic(() => import('@/components/chart'), {
loading: () => <ChartSkeleton />,
ssr: false, // サーバーレンダリングをスキップ
})
// 条件付きロード
const DynamicModal = dynamic(() => import('@/components/modal'))
export default function Page() {
const [showModal, setShowModal] = useState(false)
return (
<div>
<DynamicChart data={data} />
<button onClick={() => setShowModal(true)}>モーダルを開く</button>
{showModal && <DynamicModal onClose={() => setShowModal(false)} />}
</div>
)
}
7.5 Core Web Vitals最適化
LCP(Largest Contentful Paint)最適化:
// 最大コンテンツ要素にpriorityを設定
<Image src="/hero.jpg" alt="Hero" priority sizes="100vw" />
// 重要リソースのプリロード
<link rel="preload" href="/api/critical-data" as="fetch" crossOrigin="anonymous" />
CLS(Cumulative Layout Shift)最適化:
// 画像サイズを明示(width/height)
<Image src="/photo.jpg" width={800} height={600} alt="Photo" />
// フォントswapでFOIT防止
const font = Inter({ display: 'swap' })
// 動的コンテンツに最小高さを設定
<div style={{ minHeight: '200px' }}>
<Suspense fallback={<Skeleton height={200} />}>
<DynamicContent />
</Suspense>
</div>
INP(Interaction to Next Paint)最適化:
// 重い処理はuseTransitionで優先度を下げる
'use client'
import { useTransition } from 'react'
function SearchResults() {
const [isPending, startTransition] = useTransition()
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value) // 即座に反映(高優先度)
startTransition(() => {
// 結果フィルタリングは低優先度
setResults(filterLargeDataset(e.target.value))
})
}
return (
<div>
<input value={query} onChange={handleSearch} />
{isPending ? <Spinner /> : <ResultList results={results} />}
</div>
)
}
8. 実践プロジェクト:フルスタックダッシュボード
8.1 プロジェクト構造
my-dashboard/
app/
(auth)/
login/page.tsx
register/page.tsx
layout.tsx
(dashboard)/
dashboard/
page.tsx
loading.tsx
error.tsx
settings/
page.tsx
layout.tsx
api/
webhooks/
route.ts
layout.tsx
actions/
auth.ts
dashboard.ts
lib/
prisma.ts
auth.ts
utils.ts
components/
ui/
charts/
forms/
prisma/
schema.prisma
8.2 認証:Auth.js v5(NextAuth.js v5)
// lib/auth.ts
import NextAuth from 'next-auth'
import Google from 'next-auth/providers/google'
import Credentials from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from './prisma'
import bcrypt from 'bcryptjs'
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Google,
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
authorize: async (credentials) => {
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
})
if (!user || !user.password) return null
const valid = await bcrypt.compare(credentials.password as string, user.password)
return valid ? user : null
},
}),
],
callbacks: {
authorized: async ({ auth: session }) => {
return !!session
},
session: ({ session, token }) => ({
...session,
user: { ...session.user, id: token.sub },
}),
},
pages: {
signIn: '/login',
},
})
// middleware.ts
export { auth as middleware } from '@/lib/auth'
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
}
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/lib/auth'
export const { GET, POST } = handlers
8.3 データベース:Prisma + PostgreSQL
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
password String?
image String?
accounts Account[]
sessions Session[]
dashboards Dashboard[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Dashboard {
id String @id @default(cuid())
name String
userId String
user User @relation(fields: [userId], references: [id])
widgets Widget[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Widget {
id String @id @default(cuid())
type String
title String
config Json
dashboardId String
dashboard Dashboard @relation(fields: [dashboardId], references: [id])
position Int
}
8.4 状態管理:TanStack Query + Server Components
// app/(dashboard)/dashboard/page.tsx(Server Component)
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { DashboardClient } from './dashboard-client'
export default async function DashboardPage() {
const session = await auth()
if (!session?.user?.id) return null
// サーバーで初期データをfetch
const dashboard = await prisma.dashboard.findFirst({
where: { userId: session.user.id },
include: { widgets: { orderBy: { position: 'asc' } } },
})
// 初期データをClient Componentに渡す
return <DashboardClient initialData={dashboard} />
}
// app/(dashboard)/dashboard/dashboard-client.tsx
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { updateWidget } from '@/actions/dashboard'
export function DashboardClient({ initialData }: { initialData: Dashboard | null }) {
const queryClient = useQueryClient()
// サーバーデータを初期値として、以降はクライアントで更新
const { data: dashboard } = useQuery({
queryKey: ['dashboard'],
queryFn: () => fetch('/api/dashboard').then((r) => r.json()),
initialData,
staleTime: 60_000, // 1分
})
const mutation = useMutation({
mutationFn: updateWidget,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
},
})
return (
<div className="grid grid-cols-3 gap-4">
{dashboard?.widgets.map((widget) => (
<WidgetCard key={widget.id} widget={widget} onUpdate={(data) => mutation.mutate(data)} />
))}
</div>
)
}
8.5 デプロイ:Vercel Edge Runtime
// app/api/fast-api/route.ts
export const runtime = 'edge' // Edge Runtimeを使用
export async function GET(request: Request) {
const url = new URL(request.url)
const query = url.searchParams.get('q')
// 最寄りのリージョンで実行 — 低レイテンシ
const data = await fetch(`https://api.example.com/search?q=${query}`, {
next: { revalidate: 60 },
})
return Response.json(await data.json())
}
{
"regions": ["icn1", "nrt1"],
"functions": {
"app/api/**": {
"memory": 1024,
"maxDuration": 30
}
}
}
9. Pages RouterからApp Routerへのマイグレーション
9.1 段階的マイグレーション戦略
App RouterとPages Routerは**共存(きょうぞん)可能(かのう)**です。これを活用(かつよう)した段階的(だんかいてき)マイグレーション戦略:
Phase 1:レイアウトのマイグレーション
app/layout.tsxを作成(ルートレイアウト)
pages/_app.tsxのロジックをapp/layout.tsxへ
Phase 2:静的ページから移動
pages/about.tsx -> app/about/page.tsx
pages/pricing.tsx -> app/pricing/page.tsx
Phase 3:動的ルートのマイグレーション
pages/blog/[slug].tsx -> app/blog/[slug]/page.tsx
getServerSideProps -> async Server Component
Phase 4:API Routesの整理
内部呼び出し -> Server Actionsに置き換え
外部連携 -> app/api/route.ts(Route Handlers)
Phase 5:最適化
キャッシング戦略の設定
Parallel Routes、Intercepting Routesの適用
9.2 getServerSidePropsからServer Componentsへ
// Before:pages/blog/[slug].tsx
export async function getServerSideProps({ params }) {
const post = await prisma.post.findUnique({
where: { slug: params.slug },
})
if (!post) {
return { notFound: true }
}
return {
props: { post: JSON.parse(JSON.stringify(post)) },
}
}
export default function BlogPost({ post }) {
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
// After:app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { prisma } from '@/lib/prisma'
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await prisma.post.findUnique({
where: { slug },
})
if (!post) {
notFound() // 自動的にnot-found.tsxをレンダリング
}
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
9.3 getStaticPropsからgenerateStaticParamsへ
// Before:pages/products/[id].tsx
export async function getStaticPaths() {
const products = await prisma.product.findMany({ select: { id: true } })
return {
paths: products.map((p) => ({ params: { id: p.id } })),
fallback: 'blocking',
}
}
export async function getStaticProps({ params }) {
const product = await prisma.product.findUnique({
where: { id: params.id },
})
return {
props: { product: JSON.parse(JSON.stringify(product)) },
revalidate: 3600,
}
}
// After:app/products/[id]/page.tsx
import { prisma } from '@/lib/prisma'
export async function generateStaticParams() {
const products = await prisma.product.findMany({ select: { id: true } })
return products.map((p) => ({ id: p.id }))
}
// dynamicParams = trueがデフォルト(fallback: 'blocking'と同等)
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const product = await prisma.product.findUnique({ where: { id } })
return (
<div>
<h1>{product?.name}</h1>
<p>{product?.description}</p>
</div>
)
}
9.4 API RoutesからRoute HandlersとServer Actionsへ
// Before:pages/api/posts.ts
export default async function handler(req, res) {
if (req.method === 'GET') {
const posts = await prisma.post.findMany()
return res.json(posts)
}
if (req.method === 'POST') {
const post = await prisma.post.create({ data: req.body })
return res.status(201).json(post)
}
}
// After(外部連携用):app/api/posts/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET() {
const posts = await prisma.post.findMany()
return NextResponse.json(posts)
}
// 内部データ変更はServer Actionで代替
// actions/posts.ts
;('use server')
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const post = await prisma.post.create({
data: {
title: formData.get('title') as string,
content: formData.get('content') as string,
},
})
revalidatePath('/blog')
return post
}
9.5 注意点と落とし穴
1. Client Componentsにシリアライズ不可能なデータを渡さない:
// エラー:Dateオブジェクトはシリアライズ不可
<ClientComponent date={new Date()} />
// 修正:文字列に変換
<ClientComponent date={new Date().toISOString()} />
2. Server ComponentsでContextは使用不可:
// エラー:Server ComponentsでuseContextは使用不可
// 修正:Client ComponentにProviderを分離
// app/providers.tsx
'use client'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ThemeProvider>
)
}
// app/layout.tsx(Server Component)
import { Providers } from './providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
3. 環境変数へのアクセス:
// NEXT_PUBLIC_プレフィックスがない環境変数はサーバーでのみアクセス可能
// Server Componentsでは自由に使用
const dbUrl = process.env.DATABASE_URL // サーバー専用
// Client ComponentsではNEXT_PUBLIC_プレフィックスが必要
const apiUrl = process.env.NEXT_PUBLIC_API_URL // クライアントでもアクセス可能
4. サードパーティライブラリの互換性:
多(おお)くのライブラリがまだServer Componentsをサポートしていません。対処法(たいしょほう):
// ラッパーClient Componentを作成
// components/client-only-lib.tsx
'use client'
import { SomeClientLib } from 'some-client-lib'
export function ClientOnlyWrapper(props: any) {
return <SomeClientLib {...props} />
}
// Server Componentで使用
import { ClientOnlyWrapper } from '@/components/client-only-lib'
export default function Page() {
return <ClientOnlyWrapper data={serverData} />
}
10. クイズ
Q1. React 19でuseMemo、useCallback、memoを置き換える機能は何ですか?
React Compiler(以前の名称React Forget)です。ビルド時にコンポーネントを分析して自動的にメモ化を適用します。開発者が手動でメモ化フックを書く必要がなくなり、コードが簡潔になりミスを減らせます。
Next.js 15では、next.config.jsのexperimental.reactCompiler: trueで有効化できます。
Q2. Next.js 15でparams、searchParams、cookies()、headers()がすべて非同期に変更された理由は何ですか?
**PPR(Partial Pre-Rendering)**の最適化のためです。リクエストデータのアクセスを実際の使用時点まで遅延できるようになり、静的部分はビルド時に事前レンダリングし、動的部分(リクエストデータに依存する部分)のみランタイムで処理できます。これにより、1つのページで静的シェルを即座に提供し、動的コンテンツをストリーミングすることが可能になりました。
マイグレーションにはnpx @next/codemod@canary next-async-request-api .コードモッドを使用できます。
Q3. Client Component内でServer Componentの子要素をServer Componentのまま維持するパターンは何ですか?
Composition(合成)パターンまたはchildrenパターンです。
Server ComponentがClient Componentをレンダリングしながら、サーバーで事前レンダリングされたコンテンツをchildren propとして渡します。Client Componentはchildrenをそのままレンダリングするだけなので、渡されたServer Componentsはサーバーでレンダリングされた状態を維持します。
注意:Client Component内でServer Componentを直接importすると、そのServer Componentは自動的にClient Componentに変換されます。
Q4. Next.js 15の4つのキャッシングレイヤーを説明し、それぞれの位置(サーバー/クライアント)を区別してください。
-
Request Memoization(サーバー):同じレンダリングサイクル内で同一のfetchリクエストを自動的に重複排除します。Reactの機能です。
-
Data Cache(サーバー):fetchレスポンスをサーバーに永続的に保存します。Next.js 15ではデフォルト無効(no-store)で、明示的に有効化する必要があります。
-
Full Route Cache(サーバー):ビルド時に生成された静的ルートのHTMLとRSC Payloadを保存します。SSG/ISRルートに適用されます。
-
Router Cache(クライアント):訪問したルートのRSC Payloadをブラウザメモリにキャッシュします。Next.js 15ではデフォルト0秒(常にサーバーから最新データを取得)です。
Q5. Server ActionsとRoute Handlers(API Routes)は、それぞれどのような状況で使用するのが適切ですか?
Server Actions:
- フォーム送信とデータ変更(CRUD)操作
- Progressive Enhancementが必要な場合(JS無効環境での対応)
- 型安全なサーバー関数呼び出しが必要な場合
- revalidatePath/revalidateTagとの自然な統合
- 内部データ変更(DB更新、ファイル保存など)
Route Handlers:
- 外部システムとの連携(Webhook受信、OAuthコールバックなど)
- サードパーティサービスが呼び出すAPIエンドポイント
- ファイルダウンロード、画像生成などカスタムレスポンスが必要な場合
- REST APIを外部に公開する必要がある場合
一般的な原則:内部データ変更にはServer Actions、外部システム連携にはRoute Handlersを使用します。
11. 参考資料
- React 19公式ブログ — React v19リリースノート
- Next.js 15公式ブログ — Next.js 15リリースノート
- Next.js公式ドキュメント — App Router
- Next.js公式ドキュメント — Server Components
- Next.js公式ドキュメント — Server Actions and Mutations
- Next.js公式ドキュメント — Caching
- React公式ドキュメント — use() Hook
- React公式ドキュメント — React Compiler
- Vercelブログ — Partial Pre-Rendering
- Next.js公式ドキュメント — Turbopack
- Auth.js(NextAuth.js v5)公式ドキュメント
- Prisma公式ドキュメント — Next.js統合
- TanStack Query公式ドキュメント — Next.js Integration
- Next.js公式ドキュメント — Pages Routerからの移行
- Web.dev — Core Web Vitals
- Vercelブログ — How React Server Components Work
- Next.js GitHub — next/after RFC
- Next.js公式ドキュメント — Middleware