32blogby StudioMitsu
nextjs11 min read

Next.js App RouterでSSRが効かない原因と修正

SSRのはずなのに静的になる・キャッシュが効きすぎる。App Routerで起こりがちなSSRトラブルの原因パターンと修正方法を解説。

nextjsSSRApp Routerキャッシュ動的レンダリングデバッグ
目次

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かどうかを決めるもので、「このコンポーネントはサーバーで実行しない」という意味ではない。

tsx
// よくある誤解: "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 関数で書く。

tsx
// 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は以下の条件が全て揃ったとき、ページを静的生成に最適化する。

  1. 動的なAPIを使っていない(cookies()headers() など)
  2. searchParams を参照していない
  3. fetch にキャッシュ無効化オプションがない
  4. export const dynamic = "force-dynamic" を設定していない

つまり、普通にServer Componentを書いてデータ取得するだけだと、Next.jsはそれを「ビルド時に実行してキャッシュできる」と判断してSSGにする。

確認方法: ビルドログを読む

bash
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で強制的に動的レンダリング

最もシンプルな修正。ページファイルにエクスポートを追加するだけ。

tsx
// 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 が混在していると問題になる。

tsx
// 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} />;
}
tsx
// 毎回最新データを取得する
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はそのページを動的レンダリングとして扱う。

tsx
// 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 など)を参照するだけで動的レンダリングになる。

tsx
// 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を使って、特定のイベント(コンテンツ更新など)をトリガーにキャッシュを破棄する方法もある。

tsx
// 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>;
}
tsx
// 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 で制御できる。

tsx
// 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"
全員同じページが表示されるユーザー固有データの取得がCSRcookies() / headers() でSSRをトリガー
ビルドログが になっている動的APIを使っていないdynamic = "force-dynamic" を追加
更新してもキャッシュが残るforce-cache が設定されているrevalidate または revalidateTag

「SSRのつもりなのになぜ...」という場合、まずビルドログの ○/λ/● を確認するのが一番早い。そこから原因を逆引きすると解決策がすぐ見つかる。