32blogby StudioMitsu
nextjs12 min read

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

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

nextjsSSRServer ComponentsApp Routerデータ取得キャッシュ
目次

32blog.comをNext.js 16のApp Routerで構築したとき、一番頭を悩ませたのがSSR(サーバーサイドレンダリング)の新しい考え方だった。

Pages Routerの getServerSidePropsgetStaticProps に慣れていたので、App RouterになってServer Componentsが登場したとき「これはどう使い分ければいいんだ?」と相当時間がかかった。この記事では、その疑問を全部まとめて答えていく。

読めば、App RouterでSSRとSSGをどう使い分けるべきか、データ取得をどこに書くべきか、キャッシュをどう制御するかが分かるはずだ。

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へのリクエストを直接書いても、そのコードはブラウザに送られない。

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でのデータベースアクセス)はキャッシュの仕組みが違う。

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

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"];
  const categories = ["nextjs", "react", "claude-code", "ffmpeg", "gaming"];

  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;
}

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" }];
}

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+)や useFormStatus と組み合わせる必要がある。

まとめ

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

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

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