「Next.js 16の use cache って、結局 unstable_cache と何が違うの?」「PPRって本番で使っていいの?」——App Routerのキャッシュは、バージョンごとにメンタルモデルが変わってきました。Next.js 16で Cache Components が正式化されたことで、ようやく一貫した1つの考え方に収束します。
私自身、金融リテラシー教育のサブスク学習プラットフォームを Next.js 16 + Turborepo のモノレポ(受講者アプリ/運営管理画面/14の共有パッケージ)で構築・運用しています。この記事は、その実装で「どの機能を・いつ・どう使うか」を、Next.js公式ドキュメント(v16.2.9 時点)に忠実に、しかし公式より判断軸を厚くして解説するものです。コードはすべて、実際に手を動かせる粒度で載せます。
本記事の基準バージョン:Next.js 16.2.9 / React 19.2(React Compiler 有効)/ TypeScript 5 strict。デプロイ先は Vercel(Node.js 24 ランタイム、関数タイムアウト既定 300秒)を想定。Cache Components は
next.config.tsのcacheComponents: trueで有効化します。有効化しない場合は従来モデルの挙動になります。
0. 全体像:App Routerは「4つのレイヤー」を設計するゲーム
App Routerを本番で使うとは、実質この4つを設計することです。混同すると、遅い・落ちる・秘密が漏れる、のどれかが起きます。
| レイヤー | 役割 | 中心API | この記事の章 |
|---|---|---|---|
| 実行環境 | サーバ/クライアントの境界 | RSC既定 / "use client" | §1 |
| データ取得 | DB/API読み取りとストリーミング | async Component / fetch / <Suspense> | §2 |
| キャッシュ | 静的シェルと無効化 | "use cache" / cacheLife / cacheTag | §3 |
| 書き込み | ミューテーションと再検証 | Server Actions / updateTag / revalidateTag | §4 |
順番に意味があります。まずサーバで描き(§1)、必要なデータを並列で取り(§2)、変わらないものはキャッシュし(§3)、変えるときだけ安全に書く(§4)。以下、この流れで進めます。
1. Server Components が既定。"use client" は「足し算」で使う
最初に頭を切り替えるべき点はここです。App Routerでは、layout と page は既定で Server Component。クライアントに送るJavaScriptを最小化し、DBやAPIにサーバ側で近づき、秘密を漏らさずに描画できます。"use client" は「インタラクティブな葉っぱ」にだけ足していくものです。
1-1. 判断軸:どちらで書くか
公式の基準はシンプルです。ブラウザの能力が要るときだけ Client。それ以外は全部 Server。
| 観点 | Server Component(既定) | Client Component("use client") |
|---|---|---|
| データ取得 | DB/APIへ直接 await | 不可(props か use で受け取る) |
| 秘密の利用 | OK(バンドルに出ない) | NG(バンドルに焼き込まれる) |
| state / イベント | 不可 | useState / onClick 等 |
| ブラウザAPI | 不可 | window / localStorage 等 |
| 送るJS量 | ゼロ | コンポーネント+依存が乗る |
1-2. 実コード:Server が取得し、Client が触る
サーバでデータを取り、インタラクションが要る部分だけ props で Client に渡す——これが基本形です。
// app/lessons/[id]/page.tsx — Server Component(既定。"use client" は書かない)
import { getLesson } from '@/lib/lessons'
import { ProgressTracker } from './progress-tracker'
export default async function Page({
params,
}: {
params: Promise<{ id: string }> // Next.js 16では params は Promise
}) {
const { id } = await params // 非同期リクエストAPIなので await する
const lesson = await getLesson(id)
return (
<article>
<h1>{lesson.title}</h1>
{/* 静的な本文はサーバで描画。JSは1バイトも送らない */}
<div>{lesson.body}</div>
{/* 進捗の操作だけ Client に委譲 */}
<ProgressTracker lessonId={id} initial={lesson.progress} />
</article>
)
}
// app/lessons/[id]/progress-tracker.tsx — ここだけ Client
'use client'
import { useState } from 'react'
export function ProgressTracker({
lessonId,
initial,
}: {
lessonId: string
initial: number
}) {
const [progress, setProgress] = useState(initial)
return (
<button onClick={() => setProgress((p) => Math.min(p + 10, 100))}>
進捗 {progress}%
</button>
)
}
1-3. 境界の正しい引き方:Server を Client の children に流す
"use client" を付けた瞬間、そのファイルが import するものは全部クライアントバンドルに入ります。だからといってインタラクティブな親に全部巻き込まれないように、Server Component は children(スロット)として渡すのが定石です。スロットはサーバで描画され、結果だけが Client に注入されます。
// app/components/modal.tsx — Client(開閉state を持つ「枠」)
'use client'
import { useState } from 'react'
export function Modal({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>開く</button>
{open && <div role="dialog" aria-modal="true">{children}</div>}
</>
)
}
// app/dashboard/page.tsx — Server。重い集計コンポーネントを children で渡す
import { Modal } from '@/app/components/modal'
import { BillingSummary } from './billing-summary' // Server Component
export default function Page() {
return (
<Modal>
{/* BillingSummary はサーバで描画され、結果だけ Modal に渡る */}
<BillingSummary />
</Modal>
)
}
判断軸:Context Provider はどこに置くか? React Context は Server Component では使えません。テーマや認証の Provider は「
childrenを受ける Client Component」にして、ツリーのできるだけ深くに置きます。<html>全体を包むと静的最適化が効かなくなるため、{children}だけを包むのが公式推奨です。
2. データ取得:ウォーターフォールを潰し、ストリーミングで体感を上げる
Server Component は async 関数にして、fetch でも ORM でも直接 await できます。クライアントに秘密や接続情報が漏れないのが本質的な利点です。
// app/lib/lessons.ts — ORM 直叩き(接続情報はサーバに留まる)
import { db } from '@/lib/db'
export async function getLesson(id: string) {
// 認証・認可はここで必ず行う(後述§4の鉄則と同じ)
return db.lesson.findUniqueOrThrow({ where: { id } })
}
2-1. 同一リクエスト内の fetch は自動でメモ化される
公式の重要事実:同じURL・同じオプションの fetch は、1リクエストのコンポーネントツリー内で自動メモ化(重複排除)されます。だから「props のバケツリレーを避けるために、データが要るコンポーネントで素直に fetch する」設計が正解になります。ORM クエリなど fetch 以外を重複排除したいときは React.cache でラップします。
// app/lib/user.ts — fetch以外を1リクエスト内で重複排除する
import { cache } from 'react'
export const getCurrentUser = cache(async (userId: string) => {
return db.user.findUnique({ where: { id: userId } })
})
// 同一リクエスト中に何度呼んでもDBアクセスは1回。リクエストを跨いだ共有はされない
2-2. 最大の落とし穴:直列 await によるウォーターフォール
依存関係がないのに await を縦に並べると、リクエストが直列になり遅くなります。独立した取得は Promise.all で並列化します。
// ❌ 遅い:getAlbums は getArtist の完了を待ってしまう
const artist = await getArtist(username)
const albums = await getAlbums(username) // 不要な直列化
// ✅ 速い:両方を起動してから待つ(fetchは呼んだ瞬間に走り始める)
const artistData = getArtist(username)
const albumsData = getAlbums(username)
const [artist, albums] = await Promise.all([artistData, albumsData])
判断軸:
Promise.allかPromise.allSettledか?Promise.allは1つでも失敗すると全体が落ちます。「一部が欠けても画面を出したい」ならPromise.allSettledで個別にハンドリングします。決済サマリのように揃わないと意味がない画面はall、推奨コンテンツのように欠けても許容ならallSettled、と意図で選びます。
2-3. ストリーミング:loading.tsx と <Suspense>
遅い取得でページ全体を止めないために、部分的にストリーミングします。手段は2つ。
loading.tsx… そのセグメント全体を1つの<Suspense>で包む。ページ単位のスケルトンに最適。<Suspense>… 任意の粒度で、遅い部分だけストリーミング。静的な見出しは即表示できる。
// app/lessons/loading.tsx — ナビゲーション直後に即表示される
export default function Loading() {
return <div aria-busy="true">読み込み中…</div>
}
// app/dashboard/page.tsx — 静的部分は即時、遅い集計だけ後から流す
import { Suspense } from 'react'
export default function Page() {
return (
<section>
<h1>ダッシュボード</h1> {/* 即座にクライアントへ送られる */}
<Suspense fallback={<SummarySkeleton />}>
<BillingSummary /> {/* 遅いデータはここでストリーミング */}
</Suspense>
</section>
)
}
a11y の勘所:
loadingUI にはaria-busyを、スケルトンには意味のある代替を。遷移後のフォーカス管理(見出しへフォーカスを移す等)も「対応」ではなく最初からの前提として設計に織り込みます。スピナーだけでなく「カバー画像・タイトルなど次画面の一部」を見せると体感が上がる、と公式も推奨しています。
2-4. Client へストリーミングしたいときは use API
Client Component でサーバのデータを使いたいときは、Promise を await せずに props で渡し、Client 側で use() で読みます。<Suspense> で包めば fallback が出ます。クライアントでの再取得が中心なら、無理に自前実装せず TanStack Query を使う判断もここで行います。
// Server: await しないで Promise を渡す
export default function Page() {
const posts = getPosts() // ← await しない
return (
<Suspense fallback={<div>読み込み中…</div>}>
<Posts posts={posts} />
</Suspense>
)
}
// Client: use() で解決する
'use client'
import { use } from 'react'
export function Posts({ posts }: { posts: Promise<Post[]> }) {
const all = use(posts)
return <ul>{all.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
}
3. Cache Components:Next.js 16のキャッシュは「1つの考え方」に収束した
ここがこの記事の核心です。next.config.ts で cacheComponents: true を立てると、Next.js 16は Partial Prerendering(PPR)を既定にし、"use cache" ディレクティブで「何を静的シェルに焼くか」をあなたが明示するモデルになります。
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true, // ← これで use cache / PPR が有効になる
}
export default nextConfig
3-1. レンダリングの3分類(これだけ覚える)
Cache Components 下では、各コンポーネントはビルド時に必ず次のどれかに分類されます。これを理解すれば全部つながります。
| 分類 | 書き方 | 結果 |
|---|---|---|
| 静的(決定的) | 同期I/O・純粋計算・module import | 自動で静的シェルに入る |
| キャッシュ済み動的 | "use cache" + cacheLife | 静的シェルに焼かれ、期限で再検証 |
| 実行時動的 | <Suspense> で包む | fallback だけ静的、本体は毎リクエスト流す |
重要な公式仕様:cookies() / headers() / searchParams / params などの実行時APIに触れるコンポーネントは、<Suspense> で包むか "use cache" に値を渡すかが必須。包み忘れると、開発・ビルド時に Uncached data was accessed outside of <Suspense> エラーで構造的に弾かれます。「うっかり全ページが動的になる」事故が起きないよう、フレームワークが強制してくれるわけです。
3-2. "use cache" の3つの粒度
use cache は、ファイル/コンポーネント/関数の先頭に置けます。引数とクロージャで参照した外側の変数が、自動でキャッシュキーに含まれるのが肝です(だから別ユーザー・別パラメータは別エントリになる)。
// データ単位:複数コンポーネントで共有するデータをキャッシュ
import { cacheLife, cacheTag } from 'next/cache'
export async function getCourses() {
'use cache'
cacheLife('hours') // 後述のプロファイル
cacheTag('courses') // 後述のタグ
return db.course.findMany({ where: { published: true } })
}
// UI単位:コンポーネント丸ごとキャッシュ(props がキャッシュキーになる)
export async function CourseCard({ id }: { id: string }) {
'use cache'
const course = await getCourse(id)
return <article>{course.title}</article>
}
鉄則:キャッシュスコープ内で
cookies()/headers()を直接呼ばない。"use cache"の中で実行時APIに触れると即エラー、実行時データの Promise を await するとビルドが50秒でタイムアウトします。正しいパターンは「外側で値を読み、引数として渡す」こと(§3-4)。
3-3. cacheLife:時間ベースの鮮度設計
cacheLife で寿命を指定します。プロファイル名(seconds〜max)か、オブジェクトで細かく。既定(プロファイル無指定)は stale 5分 / revalidate 15分 / 無期限。
| profile | stale | revalidate | expire |
|---|---|---|---|
seconds | 30s | 1s | 60s |
minutes | 5m | 1m | 1h |
hours | 5m | 1h | 1d |
days | 5m | 1d | 1w |
max | 5m | 30d | 1y |
'use cache'
cacheLife({ stale: 3600, revalidate: 7200, expire: 86400 }) // 秒指定も可
コスト視点:キャッシュはサーバ関数の呼び出し回数を直接削る=Vercelの請求を下げる、という意味でもあります。一方で
secondsプロファイルやrevalidate: 0、expire5分未満は「短命」と判定され、静的シェルから外れて動的ホールになります。「ほぼ毎回再計算」なら"use cache"を付ける意味は薄く、素直に<Suspense>でストリーミングする方が誠実です。
3-4. 実行時の値をキャッシュへ渡す正しい型
「ユーザー別だが重い処理をキャッシュしたい」——cookies() を外で読み、値を引数で渡す。sessionId がキャッシュキーになり、同じセッションなら再利用されます。
import { cookies } from 'next/headers'
import { Suspense } from 'react'
export default function Page() {
return (
<Suspense fallback={<div>読み込み中…</div>}>
<ProfileContent />
</Suspense>
)
}
// 非キャッシュ:ここで実行時データを読む
async function ProfileContent() {
const sessionId = (await cookies()).get('session')?.value ?? ''
return <CachedProfile sessionId={sessionId} />
}
// キャッシュ:抽出した値だけを受け取る(sessionId がキャッシュキー)
async function CachedProfile({ sessionId }: { sessionId: string }) {
'use cache'
const data = await fetchUserData(sessionId)
return <div>{data.displayName}</div>
}
サーバーレス(Vercel等)ではインメモリキャッシュはリクエストを跨いで残らないのが既定です。リクエスト横断で共有したいなら、プラットフォーム提供の
'use cache: remote'(Redis/KV)を検討します(ストレージ・レイテンシ・課金のトレードオフあり)。
3-5. すべてが合わさる1ページ
静的・キャッシュ済み動的・実行時動的が1ページで共存します。これがPPRの実像です。
// app/blog/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife, cacheTag } from 'next/cache'
export default function BlogPage() {
return (
<>
<header><h1>ブログ</h1></header> {/* 静的:自動でシェルに入る */}
<BlogPosts /> {/* キャッシュ済み動的:シェルに焼かれる */}
<Suspense fallback={<p>設定を読み込み中…</p>}>
<UserPreferences /> {/* 実行時動的:毎回ストリーミング */}
</Suspense>
</>
)
}
async function BlogPosts() {
'use cache'
cacheLife('hours')
cacheTag('posts')
const res = await fetch('https://api.vercel.app/blog')
return <PostList posts={await res.json()} />
}
async function UserPreferences() {
const theme = (await cookies()).get('theme')?.value ?? 'light'
return <aside>テーマ: {theme}</aside>
}
3-6. unstable_cache から "use cache" への移行
v14までの unstable_cache(fn, keyParts, { tags, revalidate }) は、概念のマッピングで素直に置き換えられます。手動のキー配列が消えるのが最大の改善——引数とクロージャ変数が自動でキャッシュキーになるため、キー付け忘れによるバグが構造的に減ります。
unstable_cache | Cache Components | 補足 |
|---|---|---|
unstable_cache(fn, …) でラップ | 関数本体先頭に "use cache" | ラッパー消滅 |
keyParts(手動キー) | 関数引数 + クロージャ変数(自動) | 手動キー不要 |
tags オプション | cacheTag('…') をスコープ内で呼ぶ | 文字列タグは同じ思想 |
revalidate オプション | cacheLife({ revalidate }) / プリセット | 時間制御を分離・明示 |
// Before(v14):手動キー配列とオプションで制御
import { unstable_cache } from 'next/cache'
export const getUser = unstable_cache(
async (id: string) => db.user.findUnique({ where: { id } }),
['user'],
{ revalidate: 3600, tags: ['users'] },
)
// After(v16):ディレクティブ + 関数。id が自動でキーに入る
import { cacheLife, cacheTag } from 'next/cache'
export async function getUser(id: string) {
'use cache'
cacheLife({ revalidate: 3600 }) // または cacheLife('hours')
cacheTag('users', `user-${id}`)
return db.user.findUnique({ where: { id } })
}
同様に、v14で fetch(url, { next: { revalidate, tags } }) と暗黙キャッシュに頼っていた箇所は、"use cache" スコープ内の素の fetch に置き換えます。逆に毎回新鮮にしたい fetch は、cache: 'no-store' の呪文は不要——何も付けず <Suspense> で包むだけ。Cache Components下では「キャッシュしない」が既定だからです。
4. Server Actions:書き込みを「安全に」行う
データを変えるのは Server Functions(Server Actions)。"use server" を付けた async 関数で、フォームの action から呼べます。**JavaScript未ロードでも送信される(プログレッシブ・エンハンスメント)**のが Server Component フォームの既定挙動です。
ここは「動くデモ」と「本番で守れる実装」の差が最も出る章です。Server Actionの手軽さは「セキュリティを省略してよい」という意味ではありません——まずそのセキュリティを厚く設計し(§4-1〜4-3)、次に無効化(§4-4)、状態管理とUX(§4-5)、冪等性(§4-6)、そして「Route Handlerと使い分ける境界」(§4-7)まで通しで設計します。
4-1. 最重要の前提:Server Action は「公開HTTPエンドポイント」である
公式の警告をそのまま受け止めてください。
By default, when a Server Action is created and exported, it is reachable via a direct POST request, not just through your application's UI.
つまり "use server" を付けた関数は、ビルド後に一意のIDを持つHTTPエンドポイントとしてクライアントへ公開され、あなたのUIを経由せず直接POSTで叩けます。フォームが「ログイン済みしか見えないページ」にあっても、エンドポイント自体は誰でも叩ける。クライアント側の disabled やバリデーションは攻撃者にとって何の防御にもなりません。
Next.js 16は防御として、アクションIDをビルドごとの鍵で暗号化し、インラインのクロージャ変数も暗号化し、未使用アクションをデッドコード削除します。CSRFも「POST限定 + SameSite Cookie」で大半を緩和します。しかし公式は明言します——「それでもServer Actionは直接POSTで到達可能なものとして扱い、各アクション内で認証・認可・入力検証を行え」。プラットフォームの防御(改ざん耐性)と、アプリの防御(認可ロジック)は層が違います。
以下、本番で必ず潰すべき穴を原則として並べます。
原則1:ページの認可は、アクションの認可を保証しない
最頻出の事故がこれです。「認証済みページ内のフォームだから安全」は誤り。ページのリダイレクトは「どのUIを描くか」を制御するだけで、Server Actionは別の入口です。アクション内で毎回、認証から検証し直します。
// app/admin/page.tsx
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export default async function AdminPage() {
const session = await auth()
if (!session?.user?.isAdmin) {
redirect('/login') // ← これはUI制御にすぎない(本物の防御ではない)
}
return (
<form
action={async () => {
'use server'
// ★ アクション内で「毎回」再検証する。ここが本物の防御
const inner = await auth()
if (!inner?.user?.isAdmin) throw new Error('Unauthorized')
await db.record.deleteMany()
}}
>
<button>レコードを削除</button>
</form>
)
}
原則2:認証だけでなく「認可(リソース所有権)」を検証する(IDOR対策)
「ログイン済みか?(認証)」と「このユーザーはこの特定リソースを操作してよいか?(認可)」は別物です。後者を怠ると IDOR(Insecure Direct Object Reference)になります。クライアントから渡される postId を信用せず、所有権をDBで突き合わせます。
原則3:入力はZodで検証し、書き込み・返却フィールドを明示する
FormData / URLパラメータ / ヘッダ / searchParams はすべて改ざん可能なクライアント入力です。境界で必ず検証・ナローイングします。あわせて2つの典型脆弱性を潰します。
- マスアサインメント対策:書き込むフィールドを明示列挙する。
userId等はクライアントから受け取らず、サーバ(セッション)で決める。 - データ露出対策:返り値はUIに必要な最小DTOに絞る。生のDBレコードや内部IDをそのまま返さない。
下が、これらを1関数に通した「決定版」です。useActionState(§4-5)に渡せるよう、第1引数に prevState、戻り値の型を明示しています。
// app/lib/actions.ts
'use server'
import 'server-only'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { updateTag } from 'next/cache'
// 1) 入力スキーマ(境界での検証)
const DeletePostSchema = z.object({ postId: z.string().uuid() })
// 2) 戻り値は判別可能な最小DTO(生レコードは返さない)
export type ActionResult = { ok: true } | { ok: false; error: string }
export async function deletePost(
_prev: ActionResult | null,
formData: FormData,
): Promise<ActionResult> {
// 3) 認証:UIを信用せず、毎回ここで確認する
const session = await auth()
if (!session?.user) return { ok: false, error: 'ログインが必要です' }
// 4) 入力検証:引数を信用しない
const parsed = DeletePostSchema.safeParse({ postId: formData.get('postId') })
if (!parsed.success) return { ok: false, error: '不正な入力です' }
// 5) 認可:リソース所有権を検証(IDOR対策)
const post = await db.post.findUnique({ where: { id: parsed.data.postId } })
if (!post) return { ok: false, error: '見つかりません' }
if (post.authorId !== session.user.id) {
return { ok: false, error: '権限がありません' }
}
// 6) ミューテーション → 自分の書き込みを即反映(read-your-own-writes)
await db.post.delete({ where: { id: post.id } })
updateTag('posts')
// 7) 返り値は必要最小限
return { ok: true }
}
このアクションは 認証 → 入力検証 → 認可 → ミューテーション → 再検証 → 最小DTO返却 の7段を通過します。
原則4:薄いアクション + Data Access Layer(DAL)
認証・認可・DBアクセスを import 'server-only' のDALに集約し、"use server" アクションは「DALを呼ぶだけの薄い層」に保つのが公式推奨です。再利用とテスト容易性が上がり、認可ロジックの抜け漏れも防げます。
// data/posts.ts — server-only のデータアクセス層に認可ごと閉じ込める
import 'server-only'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
export async function deletePostOwnedBy(postId: string) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const post = await db.post.findUnique({ where: { id: postId } })
if (post?.authorId !== session.user.id) throw new Error('Forbidden')
await db.post.delete({ where: { id: post.id } })
}
import 'server-only' を付けたモジュールは、Client から import するとビルドで落ちます。秘密に触れる処理がクライアントバンドルに混入する事故を、型ではなくビルドで止められます。
原則5:クロージャの暗号化を過信しない
コンポーネント内でServer Actionを定義すると、外側スコープの変数をクローズオーバーできます。Next.jsはこの変数を自動で暗号化してクライアントへ送り返します(ビルドごとに新しい鍵)。ただし公式は警告します——「機密値の漏洩防止を暗号化だけに頼るな」。クローズオーバーには本当に必要なスナップショットだけを入れ、機密はサーバ側で都度取得します。セルフホストで複数インスタンスに分散する場合は、NEXT_SERVER_ACTIONS_ENCRYPTION_KEY を全インスタンスで共有しないと、鍵不整合で壊れます。
原則6:CSRF姿勢とレート制限
- CSRF:Server ActionはPOSTのみで起動し、Next.jsは
OriginヘッダとHost(またはX-Forwarded-Host)を比較し、不一致ならリクエストを中断します。SameSite Cookieと合わせ、現代ブラウザでは大半のCSRFを防げます。リバースプロキシ等で本番ドメインとサーバAPIが異なるときだけ、serverActions.allowedOriginsに安全なオリジンを列挙します。 - レート制限:公開エンドポイントである以上、連打・総当たりに備えます。メール送信・DB書き込みなど高コスト操作には、IPやユーザー単位のスライディングウィンドウで制限を掛けます。
セキュリティ監査チェックリスト(公式準拠)
"use server" ファイルをレビューするときの観点です。
- アクション引数は、アクション内またはDALで検証されているか(Zod等)
- アクション内でユーザーを毎回 再認証しているか
- 特定リソースの**所有権(認可)**を、認証とは別にチェックしているか(IDOR対策)
- 書き込みフィールドは明示列挙か(マスアサインメント対策)。
userId等はサーバで決めているか - 戻り値はクライアントに必要な最小DTOに絞られているか(データ露出対策)
- DBアクセスは
server-onlyのDALに委譲されているか -
[param]フォルダ由来のparams(=ユーザー入力)は検証されているか
出典:How to think about data security in Next.js / Security in Next.js(blog)
4-2. updateTag と revalidateTag の使い分け(Next.js 16の新常識)
cacheTag で付けたタグを、書き込み後に無効化します。Next.js 16では用途で2つに分かれます。
updateTag | revalidateTag | |
|---|---|---|
| 呼べる場所 | Server Actions のみ | Server Actions と Route Handlers |
| 挙動 | キャッシュを即時失効 | stale-while-revalidate |
| 使う場面 | read-your-own-writes(本人が変更を即見たい) | バックグラウンド更新(多少の遅延OK) |
// 本人が今変更したものを即見せたい → updateTag(Server Action内)
updateTag('posts')
// CMS更新の反映など、裏で差し替われば良い → revalidateTag
revalidateTag('posts', 'max') // 第2引数は stale を許容する最大時間
判断はシンプルです。「操作した本人がすぐ見る」なら updateTag、「他人にいつか届けば良い」なら revalidateTag。ルートまるごと無効化したいが対象タグが不明なときだけ revalidatePath(ただしタグの方が精密で過剰無効化が起きにくい、と公式も推奨)。
Webhook(StripeのイベントやCMSの公開)からの無効化は Route Handler に置くので、そこでは
updateTagは使えずrevalidateTagを使います。「誰がいつ無効化するか」でAPIが決まる、と覚えてください。
4-3. 状態とUX:useActionState(結果)と useFormStatus(送信中)
React 19では、フォームの「結果」と「送信中」を別々のフックで扱います。useActionState でフォーム全体の成功/エラーを、useFormStatus で送信ボタンの pending を扱うのが定石です。フォーム自体は Server Component のままにでき、プログレッシブ・エンハンスメントを保てます。
useActionState(action, initialState) は [state, formAction, pending] を返します。アクション側は第1引数に prevState を取る——§4-1の deletePost がそのまま渡せる形です。
'use client'
import { useActionState } from 'react'
import { deletePost, type ActionResult } from '@/app/lib/actions'
import { SubmitButton } from './submit-button'
const initial: ActionResult = { ok: false, error: '' }
export function PostForm({ postId }: { postId: string }) {
const [state, formAction] = useActionState(deletePost, initial)
return (
<form action={formAction}>
<input type="hidden" name="postId" value={postId} />
{/* スクリーンリーダーに結果変化を通知する */}
{!state.ok && state.error && (
<p role="alert" aria-live="polite">{state.error}</p>
)}
<SubmitButton />
</form>
)
}
pending を別コンポーネントで扱うのが useFormStatus(react-dom)です。{ pending, data, method, action } を返しますが、必ず <form> の子コンポーネント内で呼ぶ必要があります(フォーム自身のコンポーネントからは取得できません)。だから送信ボタンを切り出します。
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending} aria-busy={pending}>
{pending ? '送信中…' : '削除'}
</button>
)
}
disabled={pending} は二重送信の抑止にもなりますが、これはクライアント側の補助にすぎず、サーバ側の重複排除(§4-4)と併用してこそ冪等性が成立します。aria-live と aria-busy で、結果と処理状態を支援技術にも伝えます。楽観的UIが要るなら useOptimistic(送信前に反映し、失敗時は自動ロールバック)も選択肢です。
4-4. 冪等性とリトライ:本番運用の分かれ目
ユーザーは二度押しし、ネットワークは再送し、ブラウザはリロードします。公開エンドポイントである以上、**「同じ操作が2回実行されても結果が1回分になる」設計(冪等性)**が本番品質の境目です。守りは3層で組みます。
- DBの一意制約(最終防衛線):
UNIQUE(user_id, restaurant_id)のような制約を張れば、アプリの分岐より確実に二重実行を弾けます。 - 冪等キー:決済のような重要操作は、クライアント生成の冪等キーをアクションに渡し、サーバで「処理済みキー」を記録して重複を無効化します。
- レート制限:連打・総当たりに備え、IP/ユーザー単位で制限します(§4-1 原則6)。
// 冪等キーで「すでに処理済みなら再実行しない」を担保する
const Input = z.object({ idempotencyKey: z.string().uuid() })
export async function startCheckout(
_prev: ActionResult | null,
formData: FormData,
): Promise<ActionResult> {
const session = await auth()
if (!session?.user) return { ok: false, error: 'Unauthorized' }
const parsed = Input.safeParse({ idempotencyKey: formData.get('key') })
if (!parsed.success) return { ok: false, error: 'Invalid input' }
// 同じキーでの再POSTは「成功扱い」に正規化する(二重課金を防ぐ)
const existing = await db.checkout.findUnique({
where: { idempotencyKey: parsed.data.idempotencyKey },
})
if (existing) return { ok: true }
await db.checkout.create({
data: { idempotencyKey: parsed.data.idempotencyKey, userId: session.user.id },
})
return { ok: true }
}
想定内の失敗は
throwしない。 バリデーションエラー等は{ ok: false, error }で返すとUI制御が素直になります。一方redirect()は内部的に例外で制御を移すため、try/catchの中で呼ぶと握りつぶされます。保存成功後、catchの外で呼びます。決済の冪等性をデータ層まで掘った具体はサブスク課金基盤の記事に切り出しています。
4-5. Server Action か Route Handler(API)か——どちらを使うか
「ミューテーションはServer Action、外部公開はRoute Handler」が大原則です。無理に統一しない(YAGNI)。
| 用途 | 推奨 | 理由 |
|---|---|---|
| 自アプリのフォーム送信・データ変更 | Server Action | 型安全・/api 不要・updateTag が使える |
| JS無効でも動く堅牢性が欲しい | Server Action | <form action> がプログレッシブ・エンハンスメント |
| read-your-own-writes な即時反映 | Server Action | updateTag は Server Action 専用 |
| 外部サービス/モバイルアプリからの呼び出し | Route Handler | 安定したHTTP契約・任意クライアント |
| Webhook受信(Stripe / CMS公開) | Route Handler | 署名検証 + revalidateTag で無効化 |
| 公開REST/GraphQL・厳密なHTTPセマンティクス | Route Handler | GETキャッシュ・ステータスコード制御 |
判断のキモは無効化APIにも現れます。updateTag は Server Action でしか呼べないため、Webhook(Route Handler)からの無効化は revalidateTag 一択になります。「誰がいつ無効化するか」で入口APIが決まる、と§4-2で述べたのはこの非対称性のことです。
5. よくある落とし穴
実プロジェクトで踏みやすい順に。
5-1. ルーターキャッシュの罠(書いたのに古いまま)
Server Action で updateTag を呼んでも、クライアントのルーターキャッシュが古い画面を見せ続けるケースがあります。"use cache" のサーバ側無効化と、クライアントルーターの stale 時間(最低30秒は強制される)は別物だからです。この挙動と回避策は、再検証タイミングを実例で追ったこちらの記事で詳述しました。本記事の updateTag / revalidateTag の使い分けと合わせて読むと、画面が「いつ」更新されるかの全体像がつながります。
5-2. "use client" の付けすぎ
ページ最上部に "use client" を付けると、配下すべてがクライアントバンドルに入り、Server Component の利点(JS削減・秘密の隔離・サーバ取得)が全部消えます。インタラクティブな葉だけに付ける。検索バーだけ Client、レイアウトは Server、が正解です。
5-3. RSCから自前の /api を fetch する
ビルド時に分かるコンテンツを、Server Component から自前の /api/... 経由で取りに行くのはアンチパターンです。lib/ の関数を直接 import すれば、余計なHTTPホップ・シリアライズ・関数呼び出しコストが消えます。/api(Route Handler)は「外部Webhook受け口」「Client からの呼び出し」「BFF」のためのものです。
5-4. キャッシュスコープに実行時データを持ち込む
§3-2で触れた通り、"use cache" 内で cookies() を直接呼ぶ/実行時データの Promise を渡すと、エラーかビルドタイムアウトになります。**「外で読んで引数で渡す」**を徹底します。
5-5. 非決定的処理をキャッシュに巻き込む
Math.random() / Date.now() / crypto.randomUUID() は Cache Components 下では明示的な扱いが必要です。リクエストごとに変えたいなら connection() を呼んでから <Suspense> で包む、全員同じ値で良いなら "use cache" 内で生成、と意図で分けます。
6. 横断的な設計原則(型安全・セキュリティ・パフォーマンス・可観測性・コスト)
機能の話の最後に、本番に耐えるための背骨を、実運用で効いた順に。
- 型安全:
params/searchParamsは Promise。境界(Server Action 入力、外部API応答)は Zod でパースしてから使う。anyで逃げない。 - セキュリティ:
NEXT_PUBLIC_接頭辞の環境変数はクライアントバンドルに平文で焼き込まれる。秘密は絶対に付けない。秘密に触れるモジュールにはimport 'server-only'を付け、Client から import したらビルドで落とす。Server Action は公開エンドポイントなので中で必ず認可(§4-1の監査チェックリスト)。認証済み・ユーザー固有データを per-user キャッシュキー無しで"use cache"すると、最初のユーザーのデータが全員に配られる——これは性能問題ではなくセキュリティ事故です。識別子を引数に含めるか、そもそもキャッシュせず<Suspense>でストリーミングします。 - パフォーマンス:独立取得は
Promise.allで並列化しウォーターフォールを潰す。変わらないものは"use cache"、毎回変わるものは<Suspense>でストリーミング。 - 可観測性:キャッシュのhit/missは
NEXT_PRIVATE_DEBUG_CACHE=1で可視化できる(use cache内のconsole.logがCacheプレフィックス付きで出る)。どこまで静的シェルに焼けたかはビルド出力のサマリや生成HTMLで確認。クライアントへ伝わる寿命はx-nextjs-stale-timeヘッダで観測できます。Promise.allの失敗(1つ落ちると全滅)も握り潰さず観測する。 - コスト:
"use cache"はサーバ関数の呼び出しを直接削る。ただし短命キャッシュは動的ホール化して効かないので、寿命設計(cacheLife)を雑にしない。 - テスト:「タグ設計の単体テスト」と「read-your-own-writes のE2E確認」を分ける。
updateTagを使うアクションは、フォーム送信後に新データが即見えることをE2E(Playwright)で検証。revalidateTag(tag, 'max')経路は「次の訪問で更新」なので、即時反映を期待するテストを書くと落ちる。アクションを「薄いラッパー + DAL」に分けておけば、DALを直接呼んで「未認証/他人のリソース/不正入力」の分岐をvitestで網羅できます。
ビルドが固まるとき:ビルドがハングして
Filling a cache during prerender timed out(既定で約50秒)が出たら、"use cache"境界の外で生成された実行時データの Promise を、キャッシュ内で await しているサインです。params/searchParams/cookies()由来の Promise を、props・クロージャ・共有Map経由でキャッシュに渡していないか確認します。原則は§3-4の「外で読んで引数で渡す」。
これらは「対応項目」ではなく、設計の最初から織り込む前提です。決済の冪等性と型安全を軸にした実装の具体は、サブスク課金基盤の記事に切り出しています。本記事の App Router 基盤の上に、その決済レイヤーが乗る構成です。
まとめ:Next.js 16は「明示的なキャッシュ」で速くて安全になった
Next.js 16のApp Routerは、サーバで既定描画し(§1)、並列にデータを取り(§2)、"use cache" で何を静的シェルに焼くかを明示し(§3)、Server Action で安全に書く(§4)——という一貫した設計です。要点を5行で。
- Server Component が既定。
"use client"はインタラクティブな葉にだけ足す。Server はchildrenで Client に流す。 fetchは自動メモ化。独立取得はPromise.allで並列化し、ウォーターフォールを潰す。- Cache Components(
cacheComponents: true) で PPR が既定。"use cache"+cacheLife+cacheTagで「何を・どれだけ・どう無効化するか」を明示する。 - 無効化は用途で選ぶ:本人が即見るなら
updateTag(Server Action)、裏で差し替われば良いならrevalidateTag。実行時データは「外で読んで引数で渡す」。 - Server Action は公開エンドポイント。中で必ず認可し、入力は Zod で検証し、秘密は
server-onlyで隔離する。
「一人 × 生成AI(Claude Code)で、速く・安く・安全に」フロントからDB・課金・CIまで一気通貫で作る——その実例が、本記事のコードの出どころであるサブスク学習プラットフォーム(Next.js 16 + Turborepo モノレポ)です。App Router での新規構築・パフォーマンス改善・キャッシュ設計のご相談は、お問い合わせからどうぞ。
Sources(公式ドキュメント・Next.js v16.2.9 時点)
- Server and Client Components
- Fetching Data
- Caching(Cache Components)
- Revalidating
- use cache ディレクティブ
- cacheLife / cacheTag / updateTag / revalidateTag
- Mutating Data(Server Actions)
- How to create forms with Server Actions
- How to think about data security in Next.js / Security in Next.js(blog)
- cacheComponents 設定