32blogby Studio Mitsu

Next.js SSR完全ガイド:Server Componentsで変わったこと

App RouterのServer ComponentsでSSRがどう変わったか。データ取得・キャッシュ・ストリーミングの実践パターンを解説。

by omitsu17 min read
目次

Next.js App RouterのSSRは Server Components が中心だ。コンポーネントがデフォルトでサーバー上で動き、getServerSideProps は不要になった。データ取得はコンポーネント内に直接書き、キャッシュは fetch オプションや use cache ディレクティブで制御する。

32blog.comをNext.js 16のApp Routerで構築したとき、一番頭を悩ませたのがSSRの新しい考え方だった。Pages Routerの getServerSidePropsgetStaticProps に慣れていたので、Server Componentsが登場したとき「これはどう使い分ければいいんだ?」と相当時間がかかった。この記事では、その疑問を全部まとめて答えていく。

読めば、App RouterでSSRとSSGをどう使い分けるべきか、データ取得をどこに書くべきか、キャッシュをどう制御するかが分かるはずだ。SSRが動かないときのトラブルシューティングはこちらの記事も参考にしてほしい。

App RouterでSSRはどう変わったか?

Pages RouterのSSRは getServerSideProps という関数でリクエストごとにサーバー処理を走らせる仕組みだった。

tsx
// Pages Router(旧)
// pages/posts/[id].tsx
export async function getServerSideProps(context) {
  const { id } = context.params;
  const post = await fetchPost(id);

  return {
    props: { post },
  };
}

export default function PostPage({ post }) {
  return <article>{post.title}</article>;
}

App Routerでは、この仕組みが根本から変わった。コンポーネント自体が async になれる。

tsx
// App Router(現在)
// app/posts/[id]/page.tsx
async function fetchPost(id: string) {
  const res = await fetch(`https://api.example.com/posts/${id}`);
  return res.json();
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const post = await fetchPost(id);

  return <article>{post.title}</article>;
}

コンポーネントの中に直接 await が書ける。getServerSideProps でプロップスを引き渡す間接的な書き方がなくなり、データ取得とUIが同じファイルにまとまった。

この変化の本質は「コンポーネントがサーバー上で動く」ことが前提になったことだ。Server Componentはデフォルトでサーバーでのみ実行される。データベースや外部APIへのリクエストを直接書いても、そのコードはブラウザに送られない。このアーキテクチャの仕組みを深く理解したい場合は、React Server Components解説記事も参照してほしい。

Server ComponentとClient Componentをどう使い分けるか?

判断基準はシンプルだ。

機能Server ComponentClient Component
useState / useEffect
ブラウザAPI(localStorage等)
データベース直接アクセス
APIキー(クライアントに漏れない)
クリックイベント等のインタラクション
SEOに重要なコンテンツ

Server Componentの典型的なパターン

tsx
// app/components/ArticleList.tsx
// "use client" がない = Server Component

import { getArticles } from "@/lib/content";

export async function ArticleList({ category }: { category: string }) {
  // ここはサーバーでしか実行されない
  // APIキーをそのまま書いても安全
  const articles = await getArticles(category);

  return (
    <ul>
      {articles.map((article) => (
        <li key={article.slug}>
          <a href={`/ja/${category}/${article.slug}`}>{article.title}</a>
        </li>
      ))}
    </ul>
  );
}

Client Componentはできる限り葉ノードに置く

tsx
// app/components/LikeButton.tsx
"use client";

import { useState } from "react";

// インタラクションが必要な最小単位だけClient Componentにする
export function LikeButton({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);
  const [liked, setLiked] = useState(false);

  const handleLike = async () => {
    if (liked) return;
    setCount((c) => c + 1);
    setLiked(true);
    await fetch("/api/likes", { method: "POST" });
  };

  return (
    <button onClick={handleLike} aria-pressed={liked}>
      {liked ? "❤️" : "🤍"} {count}
    </button>
  );
}
tsx
// app/posts/[id]/page.tsx (Server Component)
import { LikeButton } from "@/components/LikeButton";

export default async function PostPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const post = await fetchPost(id);
  const likeCount = await getLikeCount(id);

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      {/* Client ComponentをServer Componentの中に埋め込む */}
      <LikeButton initialCount={likeCount} />
    </article>
  );
}

fetch のキャッシュをどう制御するか?

App Routerでは fetch がデフォルトでキャッシュされる仕様だった(Next.js 15以前)。Next.js 15からはデフォルトがキャッシュなし(no-store)に変わった。32blog.comはNext.js 16なのでデフォルトはキャッシュなしだ。

tsx
// Next.js 15+ では fetch はデフォルトで no-store
const res = await fetch("https://api.example.com/posts");

// 明示的にSSGしたい場合(ビルド時に一度だけ取得)
const res = await fetch("https://api.example.com/posts", {
  cache: "force-cache",
});

// 一定時間ごとに再生成(ISR相当)
const res = await fetch("https://api.example.com/posts", {
  next: { revalidate: 3600 }, // 1時間ごとに再検証
});

// 常に最新(SSR相当)
const res = await fetch("https://api.example.com/posts", {
  cache: "no-store",
});

ただしこれは fetch 関数に対するオプションだ。fetch を使わないデータ取得(例:Prismaでのデータベースアクセス)には use cache ディレクティブを使う。Next.js 16で安定版になった。

tsx
import { cacheLife, cacheTag } from "next/cache";

async function getRecentPosts(category: string) {
  "use cache";
  cacheLife("hours"); // 1時間キャッシュ
  cacheTag("posts");

  // fetchを使わないDB直接クエリ
  return await db.post.findMany({
    where: { category },
    orderBy: { createdAt: "desc" },
    take: 10,
  });
}

以前の unstable_cache APIもまだ動くが、レガシー扱いだ。キャッシュの無効化はServer Actionから revalidateTag() を呼ぶことで行える。

セグメント単位でキャッシュを設定する

route segment config を使うと、ページファイル全体のキャッシュ戦略を設定できる。

tsx
// app/posts/[id]/page.tsx — 静的生成(SSG相当)にする場合
export const dynamic = "force-static";
tsx
// app/posts/[id]/page.tsx — 動的レンダリング(SSR相当)にする場合
export const dynamic = "force-dynamic";
tsx
// ISR相当:60秒ごとに再生成
export const revalidate = 60;

32blog.comでは記事ページは generateStaticParams を使った静的生成にしている。

tsx
// app/[locale]/[category]/[slug]/page.tsx

// ビルド時に全記事のパラメータを生成
export async function generateStaticParams() {
  const locales = ["ja", "en", "es"];
  const categories = [
    "nextjs", "react", "vercel", "security",
    "claude-code", "ffmpeg", "cli", "gaming",
    "yocto", "renpy", "business",
  ];

  const paths = [];
  for (const locale of locales) {
    for (const category of categories) {
      const articles = await getArticlesByCategory(category, locale);
      for (const article of articles) {
        paths.push({
          locale,
          category,
          slug: article.slug,
        });
      }
    }
  }
  return paths;
}

ビルド時最適化のテクニック(generateStaticParams 含む)はNext.jsビルド最適化ガイドに詳しくまとめた。

Suspenseとストリーミングで何が変わるか?

Pages RouterのSSRは「全部レンダリングが終わってからHTMLを送信」する方式だった。App Routerは ストリーミング をサポートしている。Suspenseと組み合わせると、準備できた部分から順番にHTMLを流せる。

tsx
// app/posts/[id]/page.tsx
import { Suspense } from "react";
import { PostContent } from "@/components/PostContent";
import { RelatedPosts } from "@/components/RelatedPosts";
import { CommentSection } from "@/components/CommentSection";

export default async function PostPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  return (
    <main>
      {/* 記事本文は先に表示(APIが速い) */}
      <Suspense fallback={<PostContentSkeleton />}>
        <PostContent id={id} />
      </Suspense>

      {/* 関連記事は後から表示(別のAPIで少し遅い) */}
      <Suspense fallback={<RelatedPostsSkeleton />}>
        <RelatedPosts id={id} />
      </Suspense>

      {/* コメントは最後(最も重い処理) */}
      <Suspense fallback={<CommentsSkeleton />}>
        <CommentSection id={id} />
      </Suspense>
    </main>
  );
}

ブラウザには PostContent の準備ができた段階で最初のHTMLが送信される。関連記事やコメントはあとから追記される。ユーザーは全部揃うまで待たなくていい。

実際のメリットは LCP(Largest Contentful Paint)の改善 だ。最も重要なコンテンツ(記事本文)を先に表示できるので、ユーザーが「何も出てこない」と感じる時間が短くなる。サーバーからストリーミングされたコンテンツとクライアントレンダリングの組み合わせでハイドレーションエラーが出ることがある。その場合はハイドレーションエラー修正ガイドを参照してほしい。

SSGとSSRをどう使い分けるべきか?

判断基準を整理しておく。

ケース推奨理由
ブログ記事(内容が変わらない)SSG(静的生成)高速、CDNキャッシュ有効
商品一覧(在庫が変わる)ISR(revalidate設定)更新頻度に合わせてキャッシュ
ユーザー個人ページSSR(dynamic)ユーザーごとに異なる
ダッシュボード(リアルタイム)CSR(Client Component)サーバーレンダリング不要

32blog.comでは記事ページはSSG、トップページはSSG(ビルド時に記事一覧を生成)にしている。検索機能はClient Componentで実装した。

tsx
// app/[locale]/page.tsx
// トップページ - 静的生成

export async function generateStaticParams() {
  return [{ locale: "ja" }, { locale: "en" }, { locale: "es" }];
}

export default async function HomePage({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  // ビルド時にMDXファイルを読んで記事一覧を取得
  const latestPosts = await getLatestPosts(locale, 6);
  const categories = await getCategories(locale);

  return (
    <main>
      <HeroSection />
      <ArticleGrid posts={latestPosts} />
      <CategoryList categories={categories} />
    </main>
  );
}

Server Actionsでフォーム処理をどう書くか?

App RouterにはServer Actionsという仕組みがある。フォームの送信処理をサーバーで直接実行できる。APIルートを作らずにサーバー処理が書ける。

tsx
// app/contact/page.tsx
export default function ContactPage() {
  async function submitContact(formData: FormData) {
    "use server"; // この関数はサーバーで実行される

    const name = formData.get("name") as string;
    const email = formData.get("email") as string;
    const message = formData.get("message") as string;

    // サーバー処理(メール送信、DB保存など)
    await sendEmail({ name, email, message });

    // バリデーションやエラー処理はここに書く
  }

  return (
    <form action={submitContact}>
      <input name="name" type="text" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit">送信</button>
    </form>
  );
}

APIキーやデータベース接続情報を含む処理をブラウザに漏らさずに書けるのが最大のメリットだ。ただし、リアルタイムなバリデーションフィードバックが必要な場合は useActionState(React 19+ — 旧 useFormState からリネーム)や useFormStatus と組み合わせる必要がある。

FAQ

App RouterでもgetServerSidePropsは使えるの?

使えない。App Routerでは getServerSideProps は存在しない。Server Componentがデフォルトで async なので、データ取得はコンポーネント内に直接書く。リクエストごとの動的レンダリングが必要なら export const dynamic = "force-dynamic" を設定するか、headers() / cookies() を使う(動的レンダリングに自動オプトインされる)。

Server ComponentでuseStateやuseEffectは使える?

使えない。useStateuseEffectuseRef などのReact Hooksは "use client" を付けたClient Componentでのみ動く。インタラクションが必要な部分だけ別のClient Componentに切り出して、Server Componentからimportする。実践パターンは「use client」を最小化する方法にまとめてある。

fetchを使わないDBクエリのキャッシュはどうする?

use cache ディレクティブを使う(Next.js 16で安定版)。async関数の先頭に "use cache" を書き、cacheLife() で期間を、cacheTag() で無効化キーを設定する。以前の unstable_cache APIはレガシー扱いだ。

force-dynamicとcache: "no-store"の違いは?

force-dynamic はルートセグメント設定でページ全体に影響する。動的レンダリングを強制し、すべての fetchno-store にする。一方 cache: "no-store" は個別の fetch リクエストだけに効く。他に動的シグナルがなければページ自体は静的生成される可能性がある。

"use client"を付けるとサーバーで実行されないの?

そうではない。"use client" コンポーネントも初回リクエスト時にはサーバーでHTMLがレンダリングされる。検索エンジンには見える。違いは、そのJavaScriptがブラウザバンドルに含まれ、ハイドレーション後にフック・イベントハンドラが動くということだ。詳細はNext.jsクライアントレンダリングドキュメントを参照。

ストリーミングはSEOに影響する?

影響しない。検索エンジンのクローラーには完全にレンダリングされたHTMLが届く。GooglebotはJavaScriptを実行してコンテンツを待つので、ストリーミングで送られたServer Componentsも通常通りインデックスされる。ストリーミングはユーザー体感速度(LCP)の改善が主な効果だ。

Server ActionとAPIルートはどう使い分ける?

Server Actionsはフォーム送信やUI起点のミューテーションに使う。Reactのフォーム処理や useActionState と自然に連携する。Route Handlersは外部クライアント・Webhook・モバイルアプリ等から呼ばれるスタンドアロンのAPIエンドポイントが必要なときに使う。

まとめ

App RouterのSSRは「コンポーネントがデフォルトでサーバーで動く」という考え方に慣れるまでが難しい。整理すると:

  • Server Component — デフォルト。データ取得をコンポーネント内に直接書く。async/await が使える
  • Client Component"use client" を付ける。フック・ブラウザAPIが使える。バンドルに含まれる
  • キャッシュ — Next.js 15以降はデフォルトでキャッシュなし。明示的に force-cacherevalidate を設定する
  • ストリーミング — Suspenseで囲むと準備できた部分から順番にHTMLを送信できる
  • SSGとSSR — コンテンツの更新頻度と個別性で使い分ける

32blog.comではこの設計でビルド時に全ページを静的生成し、デプロイ後はCDNから高速配信している。記事が増えてもレスポンスタイムが変わらないのが静的生成の強みだ。