概要
Server Componentがデフォルト、Client Componentは"必要な葉"だけ
App Routerでは"use client"を宣言しない限りコンポーネントはデフォルトでServer Component。今回のコードはページ全体に"use client"を付けてしまい、SSR・サーバー側データ取得・JSバンドル削減という恩恵をすべて自ら放棄していた。
fetchキャッシュは"データの性質"で使い分ける
「常にno-store」は思考停止であり負荷とレイテンシのコストを常に払い続ける。多少の遅延を許容できるデータはnext.revalidateで緩くキャッシュし、書き込み直後の反映はrevalidatePath/revalidateTagによる明示的な再検証に任せる。
Suspenseは"遅い処理を隔離する"設計判断
集計API(2〜3秒)のように遅延が大きい処理をSuspense境界で囲むことで、遅い処理の完了を待たずに速い部分(一覧)から先にユーザーへ届けられる。バックエンドの「重い処理と軽い処理を分離する」原則と対称的。
Server Actionでもサーバー側バリデーションは必須
フォームやボタンのonClickがクライアント側でどれだけ型安全に見えても、Server Actionの引数は外部からの入力として扱い、zod等でパースしてから初めて信頼する。CLAUDE.mdのガードレール「外部入力は必ずバリデーション」の実践そのもの。
問題
MOps チームでは、これまで Cloud Run 上のバックエンド API(Python)と BigQuery を中心に扱ってきたが、今回「キャンペーン管理画面」(/admin/campaigns)の刷新を Next.js(App Router, React 19 系)で担当することになった。実装したページは一応動いてはいるものの、社内フロントエンドレビューで7つの問題を指摘された。
/admin/campaigns(キャンペーン一覧+各キャンペーンのクーポン発行数+期間集計サマリー。集計はBigQueryバックエンドAPI経由で2〜3秒かかる)+ ステータス変更(有効化/停止)フォーム
制約・前提条件
- Next.js 15系(App Router, React 19、Server Actions 安定版)
- バックエンドAPI:
GET /api/campaigns(一覧+クーポン発行数を1リクエストで返すエンドポイントが既に存在。個別N+1エンドポイントは廃止してよい)、GET /api/campaigns/summary(BigQuery集計、2〜3秒)、POST /api/campaigns/:id/status - バリデーションライブラリ:
zodが利用可能 - チャートライブラリ(
heavy-chart-lib)はサマリー表示にのみ必要で、一覧表示には不要 - 社内ガイドライン: 「ユーザー入力・外部APIレスポンスは必ずサーバー側でもバリデーションする」(クライアント側のみの検証で信頼しない)
- ステータス変更後は一覧を再取得したいが、ページ全体のフルリロードは避けたい
悪いコード (Before)
// app/admin/campaigns/page.tsx
"use client";
import { useEffect, useState } from "react";
import { LineChart } from "heavy-chart-lib"; // 問題④: 重いチャートライブラリ込みでページ全体use client
type Campaign = { id: string; name: string; status: "active" | "paused" };
export default function CampaignsPage() {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [couponCounts, setCouponCounts] = useState<Record<string, number>>({});
const [summary, setSummary] = useState<{ totalRevenue: number } | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 問題①: クライアント側useEffectでの取得 → SSRされず初回表示が真っ白になる
fetch("/api/campaigns", { cache: "no-store" }) // 問題②: 常にno-store、キャッシュ戦略なし
.then((res) => res.json())
.then(async (data: Campaign[]) => {
setCampaigns(data);
// 問題⑥: キャンペーンごとに個別fetch(N+1ウォーターフォール)
for (const c of data) {
const res = await fetch(`/api/campaigns/${c.id}/coupon-count`);
const { count } = await res.json();
setCouponCounts((prev) => ({ ...prev, [c.id]: count }));
}
// 問題⑦: 一番遅い集計APIの完了まで一覧すら表示できない
const summaryRes = await fetch("/api/campaigns/summary");
setSummary(await summaryRes.json());
setLoading(false);
});
}, []);
async function handleToggleStatus(id: string, next: "active" | "paused") {
await fetch(`/api/campaigns/${id}/status`, {
method: "POST",
body: JSON.stringify({ status: next }), // 問題⑤: サーバー側バリデーションなし
});
location.reload(); // 問題③: フルリロードでUXが悪化
}
if (loading) return <p>Loading...</p>;
return (
<div>
<h1>キャンペーン一覧</h1>
{summary && <LineChart data={summary} />}
<ul>
{campaigns.map((c) => (
<li key={c.id}>
{c.name} - クーポン発行数: {couponCounts[c.id] ?? "..."}
<button onClick={() => handleToggleStatus(c.id, c.status === "active" ? "paused" : "active")}>
{c.status === "active" ? "停止" : "有効化"}
</button>
</li>
))}
</ul>
</div>
);
}
ヒント(段階的開示)
ヒント1 — 方向性
"use client" を明示しない限りコンポーネントはデフォルトで Server Component であり、サーバー側でデータ取得とHTML生成まで済ませてからクライアントへ送るのが基本形。今回のコードは冒頭で "use client" を宣言してしまっているため、この恩恵(SSR・ゼロJSでの初期表示・サーバー側での安全なデータ取得)を自ら放棄している。(2) データ取得の並列性とキャッシュ戦略の欠如 — N+1のウォーターフォールfetchと、全APIをno-storeで毎回叩く設計は別の問題であり、「並列化すべきもの」と「キャッシュしてよいもの/都度最新にすべきもの」を区別できていない。(3) 信頼境界の欠如 — Server Action・API Routeであっても、クライアントから送られてくる値は外部入力であり、DB/BigQueryへの書き込み前にサーバー側で必ず検証が必要。
「このコンポーネントは本当にインタラクティブ(クリック・入力・状態を持つ)か?」を1つずつ問い、Yesの部分だけを小さく
"use client"に切り出すのがApp Routerの基本設計思想であることを意識する。
ヒント2 — アプローチ
- 問題①②: ページ全体
"use client"+useEffectでのfetch → ページを async Server Component にし、fetch()をトップレベルで直接awaitする(Server Component内のfetchはNext.jsのデータキャッシュ層と統合される) - 問題③:
location.reload()によるフルリロード → Server Action でステータス変更を実行し、成功後にrevalidatePath("/admin/campaigns")(またはrevalidateTag)でそのルートのキャッシュだけを再検証する - 問題④: 重いチャートライブラリを含むページ全体が
"use client"→ チャートを表示する部分だけを独立した async Server Component に切り出し、ボタン等の本当にインタラクティブな部分だけを小さな"use client"コンポーネントにする - 問題⑤: サーバー側バリデーションなし → Server Action内で
zodのsafeParse()を通してから初めてバックエンドAPIを呼ぶ - 問題⑥: キャンペーンごとの個別fetch(N+1) → バックエンドが返す集約済みエンドポイント
/api/campaignsをそのまま使う(クライアント側でのループfetchを丸ごと削除する) - 問題⑦: 一番遅い集計APIの完了を全体で待つ → 一覧部分と集計サマリー部分を別々の async Server Component に分離し、サマリー部分だけを
<Suspense fallback={...}>で包んでストリーミングする
ヒント3 — コードの骨格
// app/admin/campaigns/page.tsx (Server Component, asyncでOK)
import { Suspense } from "react";
import { CampaignSummary } from "./campaign-summary";
import { StatusToggleButton } from "./status-toggle-button"; // "use client"はここだけ
async function getCampaigns() {
const res = await fetch(`${process.env.API_BASE_URL}/api/campaigns`, {
next: { revalidate: 30, tags: ["campaigns"] }, // キャッシュ + タグ付け
});
return res.json();
}
export default async function CampaignsPage() {
const campaigns = await getCampaigns(); // サーバー側で直接await
return (
<div>
<h1>キャンペーン一覧</h1>
<Suspense fallback={<p>集計データを読込中...</p>}>
<CampaignSummary /> {/* 遅いAPIはここに隔離、一覧の表示をブロックしない */}
</Suspense>
<ul>
{campaigns.map((c) => (
<li key={c.id}>
{c.name} - クーポン発行数: {c.couponCount}
<StatusToggleButton campaignId={c.id} currentStatus={c.status} />
</li>
))}
</ul>
</div>
);
}
// app/admin/campaigns/actions.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
const StatusSchema = z.object({
campaignId: z.string().uuid(),
status: z.enum(["active", "paused"]),
});
export async function updateCampaignStatus(input: unknown) {
const parsed = StatusSchema.safeParse(input); // サーバー側で必ず検証
if (!parsed.success) return { ok: false, error: "不正な入力です" };
// バックエンドAPIへ反映...
revalidatePath("/admin/campaigns"); // フルリロード不要
return { ok: true };
}
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | ページ全体"use client"+useEffectでSSR放棄 | レンダリング戦略 | async Server Component化しトップレベルでawait |
| 2 | fetch常時no-storeでキャッシュ戦略なし | キャッシュ設計 | next.revalidate+tagsで性質に応じた再検証 |
| 3 | location.reloadによるフルリロード | 状態管理 | Server Action+revalidatePath/revalidateTag |
| 4 | 重量チャートライブラリ込みでページ全体"use client" | バンドル最適化 | チャートをServer Componentに分離、use clientは葉のみ |
| 5 | Server Action引数を未検証のまま送信 | 信頼境界 | zod safeParseでサーバー側必須バリデーション |
| 6 | キャンペーン毎の個別fetchでN+1ウォーターフォール | データ取得 | 集約済みエンドポイント/api/campaigns一本化 |
| 7 | 遅い集計API完了待ちで一覧まで巻き添え遅延 | ストリーミング | Suspense境界で隔離しストリーミングSSR |
構成図 — Bad vs Good(SVG)
模範解答
"use client";
useEffect(() => {
fetch("/api/campaigns", { cache: "no-store" })
.then((res) => res.json())
.then(async (data) => {
// N+1個別fetch + 遅い集計APIの完了待ち...
});
}, []);
// app/admin/campaigns/page.tsx
// デフォルトでServer Component。"use client"を宣言しない。
import { Suspense } from "react";
import { CampaignSummary } from "./campaign-summary";
import { StatusToggleButton } from "./status-toggle-button";
type Campaign = {
id: string;
name: string;
status: "active" | "paused";
couponCount: number; // バックエンド側で集約済み(N+1解消, 修正⑥)
};
/**
* キャンペーン一覧を取得する。
*
* Server Component内でのfetchはNext.jsのデータキャッシュ層と統合され、
* next.revalidate 秒間はキャッシュされたレスポンスが再利用される。
*
* Returns:
* Campaign[]: クーポン発行数を含むキャンペーン一覧
*/
async function getCampaigns(): Promise<Campaign[]> {
const res = await fetch(`${process.env.API_BASE_URL}/api/campaigns`, {
next: {
revalidate: 30, // 修正②: 30秒間キャッシュ再利用(一覧は多少の遅延を許容)
tags: ["campaigns"], // Server Action側からrevalidateTagで無効化可能
},
});
if (!res.ok) throw new Error(`campaigns fetch failed: ${res.status}`);
return res.json();
}
export default async function CampaignsPage() {
const campaigns = await getCampaigns(); // 修正①: サーバー側でawait
return (
<div>
<h1>キャンペーン一覧</h1>
{/* 修正⑦: 遅い集計APIはSuspense境界の内側に隔離 */}
<Suspense fallback={<p>集計データを読込中...</p>}>
<CampaignSummary />
</Suspense>
<ul>
{campaigns.map((c) => (
<li key={c.id}>
{c.name} - クーポン発行数: {c.couponCount}
{/* 修正④: インタラクティブな部分だけをclient componentに分離 */}
<StatusToggleButton campaignId={c.id} currentStatus={c.status} />
</li>
))}
</ul>
</div>
);
}
"use client";
import { LineChart } from "heavy-chart-lib";
// ページ全体のバンドルにチャートライブラリが含まれる
// 一覧を見るだけのユーザーにも不要なJSが配信される
// app/admin/campaigns/campaign-summary.tsx
// これもServer Component。重いチャートライブラリはここにだけ閉じ込める。
import { LineChart } from "heavy-chart-lib";
/**
* BigQuery集計API(レイテンシ大)を呼び出し、サマリーチャートを描画する。
*
* page.tsx側でSuspenseに包まれているため、このコンポーネントの解決を
* 一覧表示は待たない(ストリーミングSSR)。
*/
export async function CampaignSummary() {
const res = await fetch(`${process.env.API_BASE_URL}/api/campaigns/summary`, {
next: { revalidate: 60, tags: ["campaigns-summary"] },
});
const summary: { totalRevenue: number; series: number[] } = await res.json();
return <LineChart data={summary} />;
}
// app/admin/campaigns/actions.ts
"use server";
import { z } from "zod";
import { revalidatePath, revalidateTag } from "next/cache";
/** Server Actionへの入力スキーマ。クライアントの型を一切信頼しない。 */
const UpdateStatusSchema = z.object({
campaignId: z.string().uuid(),
status: z.enum(["active", "paused"]),
});
type UpdateStatusResult = { ok: true } | { ok: false; error: string };
/**
* キャンペーンのステータスを更新する Server Action。
*
* Args:
* input: クライアントから渡される未検証の値(unknown扱いする)
*
* Returns:
* UpdateStatusResult: 成功/失敗と失敗理由
*/
export async function updateCampaignStatus(input: unknown): Promise<UpdateStatusResult> {
const parsed = UpdateStatusSchema.safeParse(input); // 修正⑤: サーバー側必須バリデーション
if (!parsed.success) {
return { ok: false, error: "不正な入力です" };
}
const res = await fetch(
`${process.env.API_BASE_URL}/api/campaigns/${parsed.data.campaignId}/status`,
{
method: "POST",
body: JSON.stringify({ status: parsed.data.status }),
headers: { "Content-Type": "application/json" },
},
);
if (!res.ok) {
return { ok: false, error: "更新に失敗しました" };
}
revalidatePath("/admin/campaigns"); // 修正③: 一覧ページのキャッシュだけを無効化
revalidateTag("campaigns"); // 同タグを参照する他ルートも連動して無効化
return { ok: true };
}
操作: 「有効化」ボタンをクリック
Before: ページ全体がリロードされ、白画面 → 再構築
(スクロール位置リセット、集計チャートも再取得)
After : ボタンが「更新中...」に変わる(useTransitionのpending状態) →
Server Actionが成功 → revalidatePathでpage.tsxのキャッシュのみ無効化 →
一覧部分だけが差分更新され、集計チャートやスクロール位置は保持される
| 問題 | 修正内容 | 効果 |
|---|---|---|
| ① SSR放棄 | async Server Component化しトップレベルでawait | サーバー側でHTML生成、初回表示が速くなる |
| ② no-store固定 | next.revalidate=30+tagsでキャッシュ戦略化 | 不要な負荷とレイテンシを削減 |
| ③ location.reload | Server Action+revalidatePath/revalidateTag | ページ状態を保ったまま部分更新 |
| ④ ページ全体use client | CampaignSummaryを分離、use clientは葉のみ | 不要なJSバンドル配信を削減 |
| ⑤ サーバー未検証 | zod safeParseでサーバー側必須バリデーション | 外部入力を信頼境界で検証 |
| ⑥ N+1ウォーターフォール | 集約済み/api/campaigns一本化 | リクエスト数・待ち時間を削減 |
| ⑦ 遅いAPIで一覧まで遅延 | Suspense境界で隔離しストリーミング | 速い一覧を先に表示できる |
ポイント解説
"use client"に切り出す」のが基本設計。今回は「ボタンのクリックとpending状態」だけがクライアント責務であり、データ取得・レンダリングはすべてサーバー側で完結できた。revalidate: 30で緩くキャッシュし、書き込み直後の反映はrevalidatePath/revalidateTagによる明示的な再検証に任せる。「常にno-store」は最も安全に見えて実は思考停止であり、負荷とレイテンシのコストを常に払い続けている。Suspense境界で囲むことで、遅い処理の完了を待たずに速い部分(一覧)から先にユーザーへ届けられる。これはAPI設計における「タイムアウトを分離する」考え方とも通じる。onClickがクライアント側でどれだけ型安全に見えても、Server Actionの引数は外部からの入力として扱い、zod等でパースしてから初めて信頼する。これはAPI Routeやバックエンド全般に共通するガードレールの原則と同じ。実務への応用
社内Admin UI(Cloud Run上でホスト、07-26/08-02の弱点補強で扱ったIAP対象システム)を今後Next.jsで刷新する際、この「Server Component中心+Client Componentは葉のみ」の設計方針をチーム標準として展開できる。バックエンドAPI(Python)はデータ取得・書き込みに専念し、Next.js側はサーバーサイドレンダリングとキャッシュ制御に専念する役割分担が明確になる。
revalidateTagによるキャッシュ無効化は、dbtのincremental更新やArgo Workflowsのバッチ完了通知と組み合わせて「バッチ集計が更新されたらダッシュボードのキャッシュも自動で無効化する」設計に拡張できる(BigQueryバッチ完了→Pub/Sub→Next.js側のrevalidateTag呼び出しAPIをキック、という構成)。
Suspenseによる部分ストリーミングの考え方は、07-25/08-01/08-08で扱ってきたバックエンド側の「重い処理と軽い処理を分離する」設計判断と対称的。フロントもバックも「遅い処理をユーザーの待ち時間から隔離する」という同じ原則で設計できることを意識する。
今日のまとめ
"use client"をページ全体に付けてしまうことから連鎖的に発生していた。データ取得をサーバー側に戻し、キャッシュ戦略(revalidate/revalidateTag)・部分ストリーミング(Suspense)・信頼境界のバリデーション(zod+Server Action)という3つの軸で設計し直すことで、UXとセキュリティの両方を改善できることを確認した。
次のステップ
- 発展問題:
updateCampaignStatusの呼び出しが同時に複数箇所(一覧のボタン、詳細ページの別ボタン)から発生する場合の楽観的UI更新(useOptimistic)の設計を検討する。 - 発展問題:
CampaignSummaryのBigQuery集計APIがタイムアウトした場合のエラーバウンダリ(error.tsx)設計と、部分的な失敗時のフォールバック表示を追加する。 - 参考: Next.js公式ドキュメント(Server Components / Server Actions / fetchのキャッシュとrevalidate / Suspenseとストリーミング)、React公式ドキュメント(
useTransition,useOptimistic)