32blog.comを構築中、「あれ、データが更新されているのにページに反映されない」という現象に何度か遭遇した。デプロイし直しても変わらない。キャッシュをクリアしても変わらない。
原因はほぼ毎回、App Routerのレンダリング戦略の誤解だった。「SSRを使っているつもりが実は静的生成になっていた」というのが最も多いパターンだ。
この記事では、App RouterでSSRが意図通りに動かない原因パターンを整理し、それぞれの修正方法を解説する。
App Routerのレンダリング戦略を整理する
まず前提を整理する。App Routerのページは大きく3つの戦略で動く。
| 戦略 | タイミング | 用途 |
|---|---|---|
| 静的生成(SSG) | ビルド時に1回だけ | コンテンツが変わらないページ |
| 動的レンダリング(SSR) | リクエストごとに毎回 | ユーザー固有・リアルタイムデータ |
| ISR | 一定時間ごとに再生成 | たまに更新されるコンテンツ |
問題は、Next.jsが「これは静的にできる」と判断すると 自動で静的生成に切り替える ことだ。意図せずSSGになっているケースが非常に多い。
"use client" を付ければSSRが動く?
まず最初によくある誤解を潰しておく。"use client" はSSRとは関係ない。
"use client" はコンポーネントをClient Componentとしてマークするディレクティブだ。Server Componentかどうかを決めるもので、「このコンポーネントはサーバーで実行しない」という意味ではない。
// よくある誤解: "use client" を付けるとブラウザでのみ動く
"use client";
import { useState, useEffect } from "react";
export function UserProfile() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch("/api/user").then(r => r.json()).then(setUser);
}, []);
if (!user) return <p>読み込み中...</p>;
return <p>{user.name}</p>;
}
上のコードはクライアントサイドフェッチだ。SSRとは無関係で、サーバーでのデータ取得は行われない。SEOにも不利だ。
SSRでデータを取得したいなら、Server Componentの async 関数で書く。
// Server Componentでのデータ取得(SSR/SSG)
// "use client" なし = Server Component
export default async function UserProfile() {
// サーバーサイドで実行される
const user = await getUser();
return <p>{user.name}</p>;
}
なぜSSGになってしまうのか?
Next.jsは以下の条件が全て揃ったとき、ページを静的生成に最適化する。
- 動的なAPIを使っていない(
cookies()、headers()など) searchParamsを参照していないfetchにキャッシュ無効化オプションがないexport const dynamic = "force-dynamic"を設定していない
つまり、普通にServer Componentを書いてデータ取得するだけだと、Next.jsはそれを「ビルド時に実行してキャッシュできる」と判断してSSGにする。
確認方法: ビルドログを読む
npm run build
ビルドログに各ページのレンダリング戦略が表示される。
Route (app) Size First Load JS
┌ ○ / 4.2 kB 120 kB
├ ○ /[locale]/about 2.1 kB 118 kB
├ λ /[locale]/dashboard 5.3 kB 121 kB
└ ● /[locale]/blog/[slug] 8.4 kB 124 kB
○— 静的生成(SSG)λ— 動的レンダリング(SSR)●— ISR(revalidate付き静的生成)
ページが ○ になっているのに動的であるべきなら、それがSSRが効いていない原因だ。
修正パターン1: force-dynamicで強制的に動的レンダリング
最もシンプルな修正。ページファイルにエクスポートを追加するだけ。
// app/[locale]/dashboard/page.tsx
// これを追加するだけでSSRになる
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
// リクエストごとに毎回実行される
const data = await fetchDashboardData();
return <Dashboard data={data} />;
}
ただし force-dynamic を使うと 全リクエストでサーバー処理が走る のでサーバー負荷が上がる。静的にできる部分は静的にしておくほうがいい。
修正パターン2: fetchのキャッシュを明示的に無効化
Next.js 15以降はデフォルトが no-store なので不要なケースが多いが、force-cache が混在していると問題になる。
// SSRのつもりだが、force-cacheでキャッシュされる
export default async function PricePage() {
const prices = await fetch("https://api.example.com/prices", {
cache: "force-cache", // ビルド時にキャッシュ → 更新されない
}).then(r => r.json());
return <PriceTable prices={prices} />;
}
// 毎回最新データを取得する
export default async function PricePage() {
const prices = await fetch("https://api.example.com/prices", {
cache: "no-store", // キャッシュなし → 毎回サーバーで取得
}).then(r => r.json());
return <PriceTable prices={prices} />;
}
no-store を設定すると、そのfetchを含むページは自動的に動的レンダリングになる。force-dynamic を別途設定する必要はない。
修正パターン3: 動的APIを使ってSSRをトリガーする
cookies() や headers() を呼び出すと、Next.jsはそのページを動的レンダリングとして扱う。
// app/[locale]/profile/page.tsx
import { cookies } from "next/headers";
export default async function ProfilePage() {
// cookies() を呼び出すだけで動的レンダリングになる
const cookieStore = await cookies();
const sessionToken = cookieStore.get("session")?.value;
if (!sessionToken) {
redirect("/login");
}
const user = await getUserFromSession(sessionToken);
return <Profile user={user} />;
}
これは認証付きページでよく使うパターンだ。セッションクッキーを確認するためにどうしても動的レンダリングが必要になる。
修正パターン4: searchParamsを受け取るとSSRになる
URLのクエリパラメータ(?page=2 など)を参照するだけで動的レンダリングになる。
// app/[locale]/blog/page.tsx
interface SearchParams {
page?: string;
category?: string;
}
export default async function BlogPage({
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
// searchParamsを参照すると自動でSSRになる
const { page = "1", category } = await searchParams;
const posts = await getPosts({ page: Number(page), category });
return <PostList posts={posts} />;
}
ページネーションや絞り込み機能があるページは自然と動的レンダリングになる。
修正パターン5: revalidateTagで部分的に再検証
ISRを使って、特定のイベント(コンテンツ更新など)をトリガーにキャッシュを破棄する方法もある。
// app/[locale]/blog/[slug]/page.tsx
import { unstable_cache } from "next/cache";
// タグ付きキャッシュ
const getPost = unstable_cache(
async (slug: string) => {
return fetchPostFromCMS(slug);
},
["post"],
{
tags: ["posts"],
revalidate: 3600, // 1時間でも自動再検証
}
);
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return <article>{post.content}</article>;
}
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
const { secret } = await request.json();
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
// "posts" タグが付いた全キャッシュを無効化
revalidateTag("posts");
return Response.json({ revalidated: true });
}
CMSからコンテンツを更新したとき、Webhookで /api/revalidate を叩けばキャッシュが破棄されて次のアクセス時に最新データが表示される。
generateStaticParamsと動的レンダリングの関係
generateStaticParams を使ってパスを静的生成する場合、リストにないパスへのアクセスをどうするかを dynamicParams で制御できる。
// app/[locale]/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
// デフォルト: true(リストにないスラグはSSRで対応)
export const dynamicParams = true;
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return <article>{post.content}</article>;
}
dynamicParams = true(デフォルト)の場合、ビルド時に生成していないスラグへのアクセスがあると、その時点でSSRが走って動的生成される。生成された結果はキャッシュされる。
まとめ
App RouterでSSRが意図通りに動かない原因パターンをまとめる。
| 症状 | 原因 | 修正方法 |
|---|---|---|
| データが更新されない | 静的生成になっている | force-dynamic または cache: "no-store" |
| 全員同じページが表示される | ユーザー固有データの取得がCSR | cookies() / headers() でSSRをトリガー |
ビルドログが ○ になっている | 動的APIを使っていない | dynamic = "force-dynamic" を追加 |
| 更新してもキャッシュが残る | force-cache が設定されている | revalidate または revalidateTag |
「SSRのつもりなのになぜ...」という場合、まずビルドログの ○/λ/● を確認するのが一番早い。そこから原因を逆引きすると解決策がすぐ見つかる。