# Next.js 16 App Router Practical Guide: Designing Cache Components and Data Fetching with Real Code

> A practical guide to the Next.js 16 App Router. The Server/Client Components boundary, data fetching and waterfall countermeasures, Cache Components (use cache, cacheLife, cacheTag), PPR, and safe mutations with Server Actions — explained with working code.

- Published: 2026-06-24
- Author: 友田 陽大
- Tags: Next.js, React, TypeScript, パフォーマンス, アーキテクチャ設計, B2B SaaS
- URL: https://tomodahinata.com/en/blog/nextjs-16-app-router-cache-components-data-fetching
- Category: Frontend

## Key points

- Server Components are the default; add 'use client' only to interactive leaves, and the Server flows into the Client via children
- fetch within the same request is auto-memoized; parallelize independent fetches with Promise.all to crush waterfalls
- Enabling Cache Components makes PPR the default; use cache, cacheLife, and cacheTag to make explicit what gets baked into the static shell
- For invalidation, choose by purpose: updateTag if the person sees it immediately, revalidateTag if a background swap is fine
- Treat a Server Action as a public HTTP endpoint: authorize every time inside it and validate input with Zod

---

"Next.js 16's `use cache` — how is it ultimately different from `unstable_cache`?" "Is PPR OK to use in production?" — App Router caching has had its mental model change with each version. With **Cache Components** formalized in Next.js 16, it finally converges on **one consistent idea**.

I myself build and run a financial-literacy-education [subscription learning platform](/case-studies/subscription-learning-platform) on a **Next.js 16 + Turborepo monorepo** (learner app / operations admin panel / 14 shared packages). This article explains, from that implementation, "**which feature, when, and how to use**," faithful to the Next.js official docs (as of **v16.2.9**) but with **a thicker judgment axis** than the official docs. All code is at a grain you can actually run.

> The baseline versions of this article: **Next.js 16.2.9 / React 19.2 (React Compiler enabled) / TypeScript 5 strict**. The deploy target is Vercel (Node.js 24 runtime, function timeout default 300s). **Enable Cache Components with `cacheComponents: true` in `next.config.ts`**. Without enabling it, you get the behavior of [the conventional model](https://nextjs.org/docs/app/guides/caching-without-cache-components).

## 0. The Whole Picture: The App Router Is a Game of Designing "4 Layers"

Using the App Router in production is essentially designing these four. Conflate them and one of "slow, crashing, secrets leaking" happens.

| Layer | Role | Central API | Chapter in this article |
| --- | --- | --- | --- |
| Execution environment | The server/client boundary | RSC default / `"use client"` | §1 |
| Data fetching | DB/API reads and streaming | `async` Component / `fetch` / `<Suspense>` | §2 |
| Caching | The static shell and invalidation | `"use cache"` / `cacheLife` / `cacheTag` | §3 |
| Writing | Mutations and revalidation | Server Actions / `updateTag` / `revalidateTag` | §4 |

The order is meaningful. **First render on the server (§1), fetch the needed data in parallel (§2), cache what doesn't change (§3), and write only when changing, safely (§4).** We proceed in this flow.

---

## 1. Server Components Are the Default. Use `"use client"` "Additively"

The first place to flip your thinking is here. **In the App Router, layout and page are [Server Components](https://nextjs.org/docs/app/getting-started/server-and-client-components) by default.** They minimize the JavaScript sent to the client, get close to the DB and APIs on the server side, and render without leaking secrets. `"use client"` is something you **add** only to "interactive leaves."

### 1-1. The Judgment Axis: Which to Write In

The official criterion is simple. **Client only when browser capabilities are needed.** Everything else is Server.

| Aspect | Server Component (default) | Client Component (`"use client"`) |
| --- | --- | --- |
| Data fetching | Directly `await` to DB/API | Not possible (receive via props or `use`) |
| Using secrets | OK (not emitted to the bundle) | NG (baked into the bundle) |
| state / events | Not possible | `useState` / `onClick`, etc. |
| Browser APIs | Not possible | `window` / `localStorage`, etc. |
| JS sent | Zero | The component + dependencies ride along |

### 1-2. Real Code: Server Fetches, Client Touches

Fetch data on the server and pass only the part that needs interaction to the Client via props — this is the basic form.

```tsx
// 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>
  )
}
```

```tsx
// 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. Drawing the Boundary Correctly: Flow the Server into the Client's `children`

The moment you add `"use client"`, **everything that file imports goes into the client bundle**. To avoid dragging everything into an interactive parent, the standard is to **pass Server Components as `children` (slots)**. The slot is rendered on the server, and only the result is injected into the Client.

```tsx
// 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>}
    </>
  )
}
```

```tsx
// 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>
  )
}
```

> **Judgment axis: where do you put the Context Provider?** React Context can't be used in Server Components. Make providers for theme or auth a "Client Component that receives `children`" and place them **as deep in the tree as possible**. Wrapping the entire `<html>` disables static optimization, so wrapping only `{children}` is the official recommendation.

---

## 2. Data Fetching: Crush Waterfalls and Lift the Feel with Streaming

Make a Server Component an `async` function, and you can **`await` directly** with `fetch` or an ORM. The essential advantage is that secrets and connection info don't leak to the client.

```tsx
// 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` Within the Same Request Is Auto-Memoized

An important official fact: **`fetch` with the same URL and same options is auto-memoized (deduplicated) within one request's component tree**. So the design of "fetch straightforwardly in the component that needs data, to avoid prop-drilling" becomes the right answer. To deduplicate non-`fetch` things like ORM queries, wrap them with `React.cache`.

```ts
// 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. The Biggest Pitfall: Waterfalls from Serial `await`

Stack `await` vertically with no dependency and requests become **serial** and slow. Parallelize independent fetches with **`Promise.all`**.

```tsx
// ❌ 遅い：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])
```

> **Judgment axis: `Promise.all` or `Promise.allSettled`?** `Promise.all` fails entirely if even one fails. If you "want to show the screen even if part is missing," handle individually with `Promise.allSettled`. A screen like a payment summary that **is meaningless unless complete** is `all`; recommended content that **tolerates missing parts** is `allSettled` — choose by intent.

### 2-3. Streaming: `loading.tsx` and `<Suspense>`

To avoid stopping the whole page on a slow fetch, **stream partially**. There are two means.

- **`loading.tsx`** … wraps the **whole** segment in one `<Suspense>`. Best for a page-level skeleton.
- **`<Suspense>`** … at any granularity, streams only the slow part. Static headings display immediately.

```tsx
// app/lessons/loading.tsx — ナビゲーション直後に即表示される
export default function Loading() {
  return <div aria-busy="true">読み込み中…</div>
}
```

```tsx
// app/dashboard/page.tsx — 静的部分は即時、遅い集計だけ後から流す
import { Suspense } from 'react'

export default function Page() {
  return (
    <section>
      <h1>ダッシュボード</h1> {/* 即座にクライアントへ送られる */}
      <Suspense fallback={<SummarySkeleton />}>
        <BillingSummary /> {/* 遅いデータはここでストリーミング */}
      </Suspense>
    </section>
  )
}
```

> **The a11y crux**: give the `loading` UI `aria-busy`, and the skeleton a meaningful alternative. Weave post-transition focus management (moving focus to the heading, etc.) into the design as a **premise from the start**, not a "bolt-on." Showing "part of the next screen — the cover image, the title" instead of just a spinner lifts the feel, the official docs recommend too.

### 2-4. When You Want to Stream to the Client, the `use` API

When you want to use server data in a Client Component, **pass the Promise via props without awaiting it** and read it on the Client side with `use()`. Wrap it in `<Suspense>` and the fallback appears. If client-side re-fetching is central, make the judgment here not to over-engineer and use [TanStack Query](/blog/tanstack-query).

```tsx
// Server: await しないで Promise を渡す
export default function Page() {
  const posts = getPosts() // ← await しない
  return (
    <Suspense fallback={<div>読み込み中…</div>}>
      <Posts posts={posts} />
    </Suspense>
  )
}
```

```tsx
// 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's Caching Converged on "One Idea"

This is the heart of this article. Set `cacheComponents: true` in `next.config.ts` and Next.js 16 makes **Partial Prerendering (PPR) the default**, becoming a model where **you make explicit** "what gets baked into the static shell" with the `"use cache"` directive.

```ts
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true, // ← これで use cache / PPR が有効になる
}

export default nextConfig
```

### 3-1. The 3 Categories of Rendering (Just Remember These)

Under Cache Components, each component is **classified at build time into one of the following**. Understand this and everything connects.

| Category | How to write it | Result |
| --- | --- | --- |
| Static (deterministic) | Synchronous I/O, pure computation, module import | Automatically enters the static shell |
| Cached dynamic | `"use cache"` + `cacheLife` | Baked into the static shell, revalidated on expiry |
| Runtime dynamic | Wrapped in `<Suspense>` | Only the fallback is static, the body streamed per request |

An important official spec: **a component touching runtime APIs like `cookies()` / `headers()` / `searchParams` / `params` must either be wrapped in `<Suspense>` or have a value passed to `"use cache"`**. Forget to wrap it and you're **structurally rejected** at dev/build time with the [`Uncached data was accessed outside of <Suspense>`](https://nextjs.org/docs/messages/blocking-route) error. The framework enforces it so the accident of "accidentally making every page dynamic" doesn't happen.

### 3-2. The 3 Granularities of `"use cache"`

[`use cache`](https://nextjs.org/docs/app/api-reference/directives/use-cache) can be placed at the top of a file / component / function. The crux is that **the arguments and the outer variables referenced via closure are automatically included in the cache key** (so a different user or different parameter is a different entry).

```ts
// データ単位：複数コンポーネントで共有するデータをキャッシュ
import { cacheLife, cacheTag } from 'next/cache'

export async function getCourses() {
  'use cache'
  cacheLife('hours') // 後述のプロファイル
  cacheTag('courses') // 後述のタグ
  return db.course.findMany({ where: { published: true } })
}
```

```tsx
// UI単位：コンポーネント丸ごとキャッシュ（props がキャッシュキーになる）
export async function CourseCard({ id }: { id: string }) {
  'use cache'
  const course = await getCourse(id)
  return <article>{course.title}</article>
}
```

> **Iron rule: don't call `cookies()` / `headers()` directly inside a cache scope.** Touch a runtime API inside `"use cache"` and it's an **immediate error**; await a runtime-data Promise and the **build times out at 50 seconds**. The correct pattern is to **read the value outside and pass it as an argument** (§3-4).

### 3-3. `cacheLife`: Time-Based Freshness Design

Specify the lifetime with [`cacheLife`](https://nextjs.org/docs/app/api-reference/functions/cacheLife). A profile name (`seconds`–`max`) or an object for fine control. The default (no profile specified) is **stale 5 min / revalidate 15 min / no expiry**.

| profile | stale | revalidate | expire |
| --- | --- | --- | --- |
| `seconds` | 30s | 1s | 60s |
| `minutes` | 5m | 1m | 1h |
| `hours` | 5m | 1h | 1d |
| `days` | 5m | 1d | 1w |
| `max` | 5m | 30d | 1y |

```ts
'use cache'
cacheLife({ stale: 3600, revalidate: 7200, expire: 86400 }) // 秒指定も可
```

> **Cost view**: caching **directly cuts the number of server-function invocations** = lowers your Vercel bill. On the other hand, the `seconds` profile, `revalidate: 0`, or `expire` under 5 minutes is judged "short-lived" and **falls out of the static shell into a dynamic hole**. If it's "recompute almost every time," there's little point adding `"use cache"`; honestly streaming with `<Suspense>` is the better choice.

### 3-4. The Correct Form for Passing Runtime Values to the Cache

"User-specific but heavy processing you want to cache" — **read `cookies()` outside and pass the value as an argument**. The `sessionId` becomes the cache key, and the same session reuses it.

```tsx
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>
}
```

> On serverless (Vercel, etc.), **the in-memory cache doesn't persist across requests** by default. To share across requests, consider the platform-provided `'use cache: remote'` (Redis/KV) (with trade-offs in storage, latency, and billing).

### 3-5. The One Page Where Everything Comes Together

Static, cached dynamic, and runtime dynamic coexist on one page. This is the real image of PPR.

```tsx
// 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. Migrating from `unstable_cache` to `"use cache"`

The pre-v14 `unstable_cache(fn, keyParts, { tags, revalidate })` can be replaced straightforwardly by mapping concepts. The biggest improvement is that **the manual key array disappears** — since arguments and closure variables become the cache key automatically, bugs from forgetting to key are structurally reduced.

| `unstable_cache` | Cache Components | Note |
| --- | --- | --- |
| Wrap with `unstable_cache(fn, …)` | `"use cache"` at the top of the function body | Wrapper gone |
| `keyParts` (manual key) | Function arguments + closure variables (auto) | No manual key |
| `tags` option | Call `cacheTag('…')` within the scope | String tags are the same idea |
| `revalidate` option | `cacheLife({ revalidate })` / preset | Separate and explicit time control |

```ts
// 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'] },
)
```

```ts
// 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 } })
}
```

Similarly, spots that relied on `fetch(url, { next: { revalidate, tags } })` and implicit caching in v14 are replaced with a plain `fetch` inside a `"use cache"` scope. Conversely, for a `fetch` **you always want fresh**, the `cache: 'no-store'` incantation is unnecessary — attach nothing and just wrap it in `<Suspense>`. Under Cache Components, "don't cache" is the default.

---

## 4. Server Actions: Writing "Safely"

What changes data is a [Server Function (Server Action)](https://nextjs.org/docs/app/getting-started/mutating-data). An `async` function with `"use server"`, callable from a form's `action`. **Submitting even with JavaScript unloaded (progressive enhancement)** is the default behavior of a Server Component form.

This is the chapter where the gap between "a working demo" and "an implementation defensible in production" shows most. A Server Action's ease doesn't mean "you may omit security." First design that security thickly (§4-1–4-3), then invalidation (§4-4), state management and UX (§4-5), idempotency (§4-6), and finally "the boundary for using it vs. a Route Handler" (§4-7) — design it all the way through.

### 4-1. The Most Important Premise: A Server Action Is a "Public HTTP Endpoint"

Take the official warning at face value.

> 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.

That is, a function with `"use server"` is exposed to the client after build as an **HTTP endpoint with a unique ID**, and **can be hit by a direct POST without going through your UI**. Even if the form is on "a page visible only to logged-in users," the endpoint itself can be hit by anyone. The client-side `disabled` or validation is no defense at all against an attacker.

As defense, Next.js 16 **encrypts the action ID with a per-build key**, **encrypts inline closure variables too**, and **dead-code-eliminates unused actions**. It also mitigates most CSRF with "POST-only + SameSite Cookie." But the official docs state plainly — **"still treat a Server Action as reachable by a direct POST, and do authentication, authorization, and input validation inside each action."** The platform's defense (tamper resistance) and the app's defense (authorization logic) are different layers.

Below, the holes you must crush in production, laid out as principles.

#### Principle 1: Page authorization does not guarantee action authorization

The most frequent accident. "It's a form inside an authenticated page, so it's safe" is **wrong**. The page's redirect only controls "which UI to render"; the Server Action is a separate entrance. **Re-verify from authentication every time inside the action.**

```tsx
// 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>
  )
}
```

#### Principle 2: Verify not only authentication but "authorization (resource ownership)" (IDOR countermeasure)

"Is the user logged in? (authentication)" and "may this user operate this **specific resource**? (authorization)" are different. Neglect the latter and you get IDOR (Insecure Direct Object Reference). Don't trust the `postId` passed from the client; **cross-check ownership in the DB**.

#### Principle 3: Validate input with Zod, and make explicit the fields to write and return

`FormData` / URL parameters / headers / `searchParams` are all tamperable client input. Always validate and narrow at the boundary. Together, crush two typical vulnerabilities.

- **Mass-assignment countermeasure**: **explicitly enumerate** the fields to write. Don't receive `userId` etc. from the client; decide it on the server (the session).
- **Data-exposure countermeasure**: narrow the return value to the minimal DTO the UI needs. Don't return raw DB records or internal IDs as-is.

Below is the "definitive version" running these through one function. It's shaped to pass to `useActionState` (§4-5): the 1st argument is `prevState`, and the return type is explicit.

```ts
// 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 }
}
```

This action passes the seven stages **authentication → input validation → authorization → mutation → revalidation → minimal-DTO return**.

#### Principle 4: A thin action + a Data Access Layer (DAL)

The official recommendation is to consolidate authentication, authorization, and DB access in an `import 'server-only'` DAL, and keep the `"use server"` action "a thin layer that just calls the DAL." This raises reusability and testability and prevents gaps in authorization logic.

```ts
// 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 } })
}
```

A module with `import 'server-only'` fails the build if imported from a Client. You can stop the accident of secret-touching processing mixing into the client bundle, at build time rather than by types.

#### Principle 5: Don't over-trust closure encryption

Define a Server Action inside a component and you can close over outer-scope variables. Next.js **automatically encrypts** these variables and sends them back to the client (a new key per build). But the official docs warn — **"don't rely on encryption alone to prevent leakage of secret values."** Put only the truly necessary snapshot in the closure, and fetch secrets on the server side each time. If you self-host and distribute across multiple instances, share `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` across all instances or it breaks on key mismatch.

#### Principle 6: CSRF posture and rate limiting

- **CSRF**: a Server Action is triggered POST-only, and Next.js compares the `Origin` header with `Host` (or `X-Forwarded-Host`), **aborting the request** on mismatch. Combined with SameSite Cookies, it prevents most CSRF in modern browsers. Only when the production domain and the server API differ (a reverse proxy, etc.) do you enumerate safe origins in `serverActions.allowedOrigins`.
- **Rate limiting**: since it's a public endpoint, prepare for repeated hits and brute force. For high-cost operations like email sending and DB writes, apply a sliding window per IP or user.

#### Security audit checklist (official-based)

The aspects to look at when reviewing a `"use server"` file.

- [ ] Are the action arguments **validated** inside the action or in the DAL (Zod, etc.)?
- [ ] Are you **re-authenticating the user every time** inside the action?
- [ ] Are you checking **ownership (authorization) of the specific resource**, separately from authentication (IDOR countermeasure)?
- [ ] Are the write fields **explicitly enumerated** (mass-assignment countermeasure)? Is `userId` etc. decided on the server?
- [ ] Is the return value narrowed to **the minimal DTO the client needs** (data-exposure countermeasure)?
- [ ] Is DB access delegated to a `server-only` **DAL**?
- [ ] Are `params` from a `[param]` folder (= user input) validated?

> Sources: [How to think about data security in Next.js](https://nextjs.org/docs/app/guides/data-security) / [Security in Next.js (blog)](https://nextjs.org/blog/security-nextjs-server-components-actions)

### 4-2. Using `updateTag` vs. `revalidateTag` (Next.js 16's New Common Sense)

Invalidate the tag set with `cacheTag` after writing. **In Next.js 16 it splits into two by purpose.**

| | `updateTag` | `revalidateTag` |
| --- | --- | --- |
| Where it can be called | Server Actions only | Server Actions and Route Handlers |
| Behavior | **Invalidate the cache immediately** | stale-while-revalidate |
| When to use | read-your-own-writes (the person wants to see their change immediately) | Background update (some delay OK) |

```ts
// 本人が今変更したものを即見せたい → updateTag（Server Action内）
updateTag('posts')

// CMS更新の反映など、裏で差し替われば良い → revalidateTag
revalidateTag('posts', 'max') // 第2引数は stale を許容する最大時間
```

The judgment is simple. **"The person who operated sees it right away" → `updateTag`; "it's fine if it reaches others someday" → `revalidateTag`.** Only when you want to invalidate a whole route but the target tag is unknown, use `revalidatePath` (though tags are more precise and less prone to over-invalidation, the official docs recommend too).

> Invalidation from a webhook (a Stripe event or a CMS publish) goes in a **Route Handler**, so there `updateTag` can't be used — use `revalidateTag`. **The API is decided by "who invalidates and when,"** remember.

### 4-3. State and UX: `useActionState` (Result) and `useFormStatus` (Submitting)

In React 19, the form's "result" and "submitting" are handled by separate hooks. The standard is to handle **the whole-form success/error with `useActionState`, and the submit button's `pending` with `useFormStatus`**. The form itself can stay a Server Component, preserving progressive enhancement.

`useActionState(action, initialState)` returns `[state, formAction, pending]`. The action side takes `prevState` as its 1st argument — §4-1's `deletePost` can be passed as-is.

```tsx
'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>
  )
}
```

Handling `pending` in a **separate component** is `useFormStatus` (`react-dom`). It returns `{ pending, data, method, action }`, but **must be called inside a child component of the `<form>`** (you can't get it from the form's own component). So carve out the submit button.

```tsx
'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}` also suppresses double submission, but this is **only a client-side aid**; idempotency holds only combined with server-side deduplication (§4-4). With `aria-live` and `aria-busy`, convey the result and processing state to assistive tech too. If you need optimistic UI, `useOptimistic` (reflect before submission, auto-rollback on failure) is also an option.

### 4-4. Idempotency and Retries: The Watershed of Production Operation

Users double-click, networks re-send, browsers reload. Since it's a public endpoint, **a design where "the same operation executed twice results in one (idempotency)"** is the watershed of production quality. Build the defense in three layers.

1. **DB unique constraint (the last line of defense)**: a constraint like `UNIQUE(user_id, restaurant_id)` rejects double execution more reliably than app branching.
2. **Idempotency key**: for important operations like payments, pass a client-generated idempotency key to the action, record "processed keys" on the server, and nullify duplicates.
3. **Rate limiting**: prepare for repeated hits and brute force per IP/user (§4-1 Principle 6).

```ts
// 冪等キーで「すでに処理済みなら再実行しない」を担保する
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 }
}
```

> **Don't `throw` for expected failures.** Returning validation errors etc. as `{ ok: false, error }` makes UI control straightforward. On the other hand, `redirect()` internally moves control via an exception, so **calling it inside `try/catch` swallows it**. Call it outside the catch, after save success. The concrete dig into payment idempotency down to the data layer is carved out in [the subscription-billing-platform article](/blog/subscription-platform-billing-idempotency-type-safety).

### 4-5. Server Action or Route Handler (API) — Which to Use

The grand principle is "mutations → Server Action, external exposure → Route Handler." Don't force unification (YAGNI).

| Use | Recommendation | Reason |
| --- | --- | --- |
| Your app's form submission / data change | **Server Action** | Type-safe, no `/api` needed, can use `updateTag` |
| You want robustness that works even with JS disabled | **Server Action** | `<form action>` is progressive enhancement |
| read-your-own-writes immediate reflection | **Server Action** | `updateTag` is Server-Action-only |
| Calls from external services / mobile apps | **Route Handler** | Stable HTTP contract, any client |
| Webhook receipt (Stripe / CMS publish) | **Route Handler** | Signature verification + invalidation with `revalidateTag` |
| Public REST/GraphQL, strict HTTP semantics | **Route Handler** | GET caching, status-code control |

The crux of the judgment appears in the invalidation API too. Since **`updateTag` can only be called in a Server Action**, invalidation from a webhook (a Route Handler) is `revalidateTag` only. The asymmetry that "the entrance API is decided by who invalidates and when" stated in §4-2 is this.

---

## 5. Common Pitfalls

In the order you're likely to step on them in a real project.

### 5-1. The Router-Cache Trap (Written But Still Stale)

Even if you call `updateTag` in a Server Action, there are cases where **the client's router cache keeps showing a stale screen**. The server-side invalidation of `"use cache"` and the client router's `stale` time (**at least 30 seconds is forced**) are different things. This behavior and the workaround are detailed in [this article](/blog/revalidate-tag-nextjs-router-cache-trap), which followed re-validation timing with real examples. Read alongside this article's `updateTag` / `revalidateTag` usage and the whole picture of "when" the screen updates connects.

### 5-2. Over-Applying `"use client"`

Add `"use client"` at the top of a page and everything under it goes into the client bundle, erasing all the Server Component advantages (JS reduction, secret isolation, server fetch). **Add it only to interactive leaves.** Only the search bar is Client, the layout is Server — that's the right answer.

### 5-3. Fetching Your Own `/api` from an RSC

Going through your own `/api/...` from a Server Component to fetch build-time-known content is an anti-pattern. **Import a function from `lib/` directly** and the extra HTTP hop, serialization, and function-call cost disappear. `/api` (Route Handler) is for "external webhook intake," "calls from the Client," and "BFF."

### 5-4. Bringing Runtime Data into a Cache Scope

As touched on in §3-2, calling `cookies()` directly inside `"use cache"` / passing a runtime-data Promise causes an error or a build timeout. Enforce **"read outside and pass as an argument."**

### 5-5. Pulling Non-Deterministic Processing into the Cache

`Math.random()` / `Date.now()` / `crypto.randomUUID()` need explicit handling under Cache Components. **If you want it to change per request**, call `connection()` and then wrap in `<Suspense>`; **if everyone can have the same value**, generate inside `"use cache"` — split by intent.

---

## 6. Cross-Cutting Design Principles (Type Safety, Security, Performance, Observability, Cost)

To close the feature talk, the backbone for withstanding production, in the order it mattered in real operation.

- **Type safety**: `params` / `searchParams` are Promises. **Parse before using** boundaries (Server Action inputs, external API responses) with Zod. Don't escape with `any`.
- **Security**: environment variables with the `NEXT_PUBLIC_` prefix are **baked into the client bundle in plaintext**. Never attach a secret. Add `import 'server-only'` to modules touching secrets, and fail the build if imported from a Client. A Server Action is a public endpoint, so **always authorize inside** (§4-1's audit checklist). **`"use cache"`-ing authenticated, user-specific data without a per-user cache key delivers the first user's data to everyone** — this is a security accident, not a performance problem. Include the identifier in the arguments, or don't cache and stream with `<Suspense>`.
- **Performance**: parallelize independent fetches with `Promise.all` to crush waterfalls. `"use cache"` what doesn't change, stream with `<Suspense>` what changes every time.
- **Observability**: cache hit/miss can be visualized with `NEXT_PRIVATE_DEBUG_CACHE=1` (a `console.log` inside `use cache` is output with a `Cache` prefix). How far it baked into the static shell can be confirmed in the **build-output summary** or the generated HTML. The lifetime conveyed to the client can be observed via the `x-nextjs-stale-time` header. Don't swallow `Promise.all` failures (one fails and all die) — observe them.
- **Cost**: `"use cache"` **directly cuts server-function invocations**. But a short-lived cache turns into a dynamic hole and doesn't work, so don't be sloppy with the lifetime design (`cacheLife`).
- **Testing**: separate "unit testing of tag design" and "E2E confirmation of read-your-own-writes." For an action using `updateTag`, verify in E2E (Playwright) that the new data is visible immediately after form submission. The `revalidateTag(tag, 'max')` path is "updated on the next visit," so a test expecting immediate reflection will fail. Split the action into a "thin wrapper + DAL" and you can call the DAL directly to cover the branches of "unauthenticated / another's resource / invalid input" in vitest.

> **When the build hangs**: if the build hangs and you get `Filling a cache during prerender timed out` (default about 50s), it's a **sign that you're awaiting, inside the cache, a runtime-data Promise generated outside the `"use cache"` boundary**. Check whether you're passing a `params` / `searchParams` / `cookies()`-origin Promise into the cache via props, closures, or a shared Map. The principle is §3-4's "read outside and pass as an argument."

These are not "to-do items" but premises woven into the design from the start. The concrete implementation centered on payment idempotency and type safety is carved out in [the subscription-billing-platform article](/blog/subscription-platform-billing-idempotency-type-safety). That payment layer rides on top of this article's App Router foundation.

---

## Summary: Next.js 16 Became Fast and Safe with "Explicit Caching"

Next.js 16's App Router is a consistent design — **render on the server by default (§1), fetch data in parallel (§2), make explicit with `"use cache"` what gets baked into the static shell (§3), and write safely with Server Actions (§4)**. The key points in five lines.

1. **Server Components are the default.** Add `"use client"` only to interactive leaves. The Server flows into the Client via `children`.
2. **`fetch` is auto-memoized.** Parallelize independent fetches with `Promise.all` to crush waterfalls.
3. With **Cache Components (`cacheComponents: true`)**, PPR is the default. Make explicit "what, for how long, how to invalidate" with `"use cache"` + `cacheLife` + `cacheTag`.
4. **Choose invalidation by purpose**: `updateTag` (Server Action) if the person sees it immediately, `revalidateTag` if a background swap is fine. For runtime data, "read outside and pass as an argument."
5. **A Server Action is a public endpoint.** Always authorize inside, validate input with Zod, and isolate secrets with `server-only`.

"With one person × generative AI (Claude Code), fast, cheap, and safe" — building end-to-end from the frontend through DB, billing, and CI — the real example, the source of this article's code, is [the subscription learning platform](/case-studies/subscription-learning-platform) (a Next.js 16 + Turborepo monorepo). For consultation on a new build, performance improvement, or cache design with the App Router, reach out from [Contact](/contact).

---

### Sources (Official Documentation, as of Next.js v16.2.9)

- [Server and Client Components](https://nextjs.org/docs/app/getting-started/server-and-client-components)
- [Fetching Data](https://nextjs.org/docs/app/getting-started/fetching-data)
- [Caching (Cache Components)](https://nextjs.org/docs/app/getting-started/caching)
- [Revalidating](https://nextjs.org/docs/app/getting-started/revalidating)
- [use cache directive](https://nextjs.org/docs/app/api-reference/directives/use-cache)
- [cacheLife](https://nextjs.org/docs/app/api-reference/functions/cacheLife) / [cacheTag](https://nextjs.org/docs/app/api-reference/functions/cacheTag) / [updateTag](https://nextjs.org/docs/app/api-reference/functions/updateTag) / [revalidateTag](https://nextjs.org/docs/app/api-reference/functions/revalidateTag)
- [Mutating Data (Server Actions)](https://nextjs.org/docs/app/getting-started/mutating-data)
- [How to create forms with Server Actions](https://nextjs.org/docs/app/guides/forms)
- [How to think about data security in Next.js](https://nextjs.org/docs/app/guides/data-security) / [Security in Next.js (blog)](https://nextjs.org/blog/security-nextjs-server-components-actions)
- [cacheComponents config](https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents)
