Next.jsのSSR・SSG・Hydrationをテーマにしたカバー画像
Natsuki Izumi

あなたのNext.js、本当に速いですか?SSR・SSG・Hydrationのよくある勘違い

この記事のポイント

本文をもとにGemma 3が要約しています

  • SSRとSSGの仕組みと使い分け方(データの更新頻度、配信速度、サーバー負荷など)を解説。
  • Next.jsの初期表示が遅延する原因(Hydration flash、JSのシングルスレッド)と、それを回避するための設計手法を提示。
  • テーマ切り替えの実装でよくある問題点(Hydration flash)とその解決策を解説。

Next.jsを導入したのに、Lighthouseを開いたら思ったよりスコアが低くて「あれ?」となった経験、ないでしょうか。正直に言うと、私も最初はそういう感覚がありました。「Next.jsを使えば速い」という期待でプロジェクトを組み始めたのに、モバイルでは表示が重く、テーマ切り替えで画面がチカチカしました。

Next.jsが悪いわけじゃないんですよね。「そういうものだと思い込んでいた」勘違いが積み重なっていた、それが実態です。

SSRとSSGの仕組みから始まり、Hydration・バンドルサイズ・キャッシュの扱いまで、実務で踏みやすい落とし穴をまとめました。

1. SSRとSSGの違い

まず基本の用語から整理しておきます。公式の説明は Next.js の Server and Client ComponentsCaching もあわせて参照してください。

SSR(Server-Side Rendering)とは?

SSR(Server-Side Rendering)は、ユーザーのリクエストが来るたびにサーバー上でHTMLを生成して返す手法です。

リクエストのタイミングでデータを取得するため、常に最新の状態を反映できます。ログイン後のダッシュボード、ユーザーごとに表示内容が変わるページ、リアルタイムなデータ表示に向いています。ただし、リクエストのたびにサーバーでレンダリング処理が走るため、静的配信と比べてレイテンシ(通信と処理の待ち時間)が増えます。

SSG(Static Site Generation)とは?

SSG(Static Site Generation)は、ビルド時にHTMLファイルをあらかじめ生成しておく手法です。生成済みのHTMLをCDN(コンテンツ配信ネットワーク)のエッジサーバーから、静的ファイルとして配信します。

一度生成したHTMLを配信するだけなので、SSRより配信が速く、サーバー負荷も下がります。表示内容がほぼ変わらないブログ記事や、マーケティングのランディングページに向いています。

デメリットは、ビルド後にデータが更新されてもHTMLには反映されない点です。ISR(Incremental Static Regeneration。ビルド後に一部のページだけ再生成する仕組み)で補うこともできます。ただ、完全なリアルタイム性は持てません。個人的には、更新頻度が低いコンテンツならISRまで持ち出さず、シンプルなSSGで完結させる方が、運用の見通しが立ちやすいと思っています。

使い分けの基準

観点SSRSSG
データの更新頻度高い(リクエストごとに最新)低い(ビルド時に固定)
配信速度やや遅い(サーバー処理あり)速い(CDNから静的配信)
向いているページダッシュボード、ユーザー個別ページブログ、LP、静的コンテンツ
サーバー負荷高い低い
SEO対応可能対応可能

実務では、ページ単位で選ぶのが基本です。「このプロジェクトはNext.jsだからSSR」「静的サイトだからSSG」のように、プロジェクト全体を一括で決める必要はありません。

2. 「サーバー処理してるから速い」は半分正解

「Next.jsはサーバーサイドで処理するから、重いサイトでも初期表示が速い」という認識を、ときどき見かけます。この認識には、正しい側面と見落とされがちな落とし穴の両方があります。

HTMLは返るが、JSは後付け

SSRが返すのは、HTMLです。ブラウザはまずそのHTMLを受け取って表示します。ここで一見「表示された」ように見えますが、この状態ではページはまだ動きません。Reactのイベントハンドラも動いていないし、useState等のクライアント状態も初期化されていません。

その後、ブラウザがJavaScriptバンドルをダウンロードして実行します。既存のHTMLに対してReactのツリーを「接続」する処理が走ります。これがHydration(サーバーが返したHTMLと、クライアントのReactツリーを接続する処理)です。

つまり、視覚的には見えているのに操作できない、というタイムラグが生じます。JSのダウンロードと実行が終わるまで、ボタンを押しても反応しません。

JSはシングルスレッド

Hydrationや初期JSの実行が長いタスクになると、メインスレッドを占有し、操作遅延や固まりの原因になります。

初期表示に必要なコンポーネント以外は、非同期で遅延ロードします。next/dynamicコードスプリッティング(必要な部分だけを読み込む分割)します。Suspenseは、読み込み完了まで代替表示を出す仕組みです。ファーストビューに不要なコンポーネントはlazy()にします。これらを組み合わせないと、Next.jsを使っていても初期表示は普通に重くなります。

// ファーストビューに不要な重いコンポーネントは動的インポート
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
  loading: () => <Skeleton />,
  ssr: false,
});

「Next.jsを正しく使えば速くなる余地がある」というのが実態です。

3. テーマ切り替えで踏みやすい落とし穴

ダーク/ライトモードのテーマ切り替えは、実装が雑だと2つの問題を引き起こします。

問題1: Hydration flash

クライアント側でテーマを判定してから表示を切り替える実装を使うと、Hydration flash(画面が一瞬ちらつく現象)が発生します。

サーバーが返すHTML(テーマ未確定)とクライアントが確認したテーマ(localStorageprefers-color-scheme)が一致しないために起きます。Reactが「サーバーとクライアントのHTMLが違う」と検知してDOMを再構築するので、ページ読み込み時に一瞬元のテーマが見えます。ユーザー体験としては「ページを開くたびに画面がチカチカする」状態です。

問題2: CDNキャッシュが効かない

Hydration flashを「サーバー側でテーマを先読みして解決しよう」としたときが問題です。cookies()headers()でテーマを判定しようとすると、Next.jsはそのページをdynamic render(リクエストのたびにサーバーでHTMLを作る描画)に切り替えます。

静的なページはCDNのエッジサーバーにキャッシュされ、ユーザーに一番近いサーバーから配信されます。しかしdynamic renderになると、毎回オリジンサーバーまで取りに行きます。その分、レイテンシが増えます。

正しいアプローチ: CSS変数かTailwindで管理する

テーマ切り替えは、JavaScriptの状態管理に寄せず、CSSレイヤーで完結させるのが基本です。

方式仕組みメリットデメリット
CSS変数data-theme属性をscriptで先行設定フレームワーク非依存、細かい制御が可能CSS変数の設計/命名を自前で管理する必要がある
Tailwind darkMode: 'class'classかdata属性をCSSトリガーにTailwindのユーティリティクラスがそのまま使えるTailwindの設定に依存する

CSS変数方式:

:root {
  --bg: #ffffff;
  --text: #000000;
}

[data-theme="dark"] {
  --bg: #0a0a0a;
  --text: #ededed;
}
// <html>タグのdata-theme属性をscriptで先行設定する
// layout.tsxのHTMLタグ直前に差し込む
<script
  dangerouslySetInnerHTML={{
    __html: `
      (function() {
        const t = localStorage.getItem('theme') ||
          (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
        document.documentElement.setAttribute('data-theme', t);
      })()
    `,
  }}
/>

TailwindのdarkMode設定:

tailwind.config.tsdarkMode: 'class'またはdarkMode: ['class', '[data-theme="dark"]']を設定します。classかdata属性の付け替えをトリガーにしたテーマ切り替えが、CSSだけで完結します。

「テーマの判定と適用」は、HTMLの解析前に終わらせます。<body>直前に同期的なscriptを置いてHTMLタグに属性を付与しておけば、Hydration時に不一致が起きません。

4. next/imageが「動的に見えて静的配信」な理由

next/imageは、リクエスト元のデバイスやブラウザに合わせて最適化された画像を返してくれるコンポーネントです。「リクエスト内容に応じて配信が変わる」と聞くと動的配信のように見えますが、実態は違います。使い方の詳細は Next.js Image Optimization を参照してください。

URLが決定論的

next/imageが生成する画像のURLは、以下のパラメータの組み合わせで一意に決まります。

/_next/image?url=%2Fphoto.jpg&w=640&q=75

  • url: 元画像のパス
  • w: 幅(width)
  • q: 画質(quality)

同じデバイス解像度のユーザーが同じ画像にアクセスすれば、同じURLを叩きます。URLが同じならレスポンスは使い回せます。つまりimmutable(同じURLなら中身が変わらない)として扱えます。

動的処理をしているのに、扱いはimmutableです。この点が、next/imageのキャッシュが成立する理由です。

Cache-Control: immutableで強力にキャッシュ

next/image は、画像URL・幅・品質などのパラメータに基づいて最適化画像を生成し、キャッシュ可能な形で配信します。ただし、キャッシュ期間や immutable の扱いは条件で変わります。差が出るのは、Static Image Import(プロジェクト内の画像をimportして使う方式)か外部画像か、元画像の Cache-Control、minimumCacheTTL などです。Static Image Import の場合は、ファイル内容に基づくハッシュにより、長期のimmutableキャッシュがかかりやすくなります。

Cache-Control: public, max-age=31536000, immutable

immutableは「このURLのレスポンスは変わらない」という宣言です。ブラウザとCDNの両方がこの指示を受け取り、1年間(31536000秒)再フェッチしません。

next/imageはリクエスト応答に処理が走るように見えて、実質的には静的配信と同等のキャッシュ効率を持ちます。「next/imageを入れるとSSGのキャッシュが壊れる」という誤解をよく見かけますが、URL設計がimmutableである以上、その心配は不要です。

5. 巨大UIライブラリと重いアニメーションの落とし穴

Next.jsは自動でコードスプリッティングをしてくれます。ただし、重いライブラリをimportしているファイルがあれば、その重さは初期バンドルに載ります。

初期バンドルとは

初期バンドルとは、ページに初めてアクセスしたときにブラウザが最初にダウンロードして実行するJavaScriptのことです。このサイズが大きいほど、初期表示までの時間が伸びます。

デスクトップのWi-Fi環境では問題なくても、モバイルの4G/LTE環境やCPUが非力な端末では固まります。目安として、初期JSバンドルが200〜300KBを超えると、ミドルレンジのAndroid端末ではTTI(Time to Interactive / 操作できるまでの時間)が体感で遅くなりやすくなります。「PCで見ると速い、スマホで重い」は、これが原因であることがよくあります。

CSSアニメーションをCompositorスレッドへ逃がす

アニメーションを実装する場合、できる限りCSSで構築するのが基本です。

transformopacityを使ったCSSアニメーションは、ブラウザの描画専用スレッド(Compositorスレッド)で処理されます。このスレッドはJavaScriptのメインスレッドと独立しています。JSが重くても、アニメーションは止まりにくいのです。

JavaScriptでDOMを直接操作するアニメーションはメインスレッドで処理されるため、重い処理と一緒に止まります。

/* CSSアニメーション(Compositorスレッドで処理) */
.fade-in {
  animation: fadeIn 0.3s ease;
}

@keyframes fadeIn {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
}

CSSだけでは厳しい演出が必要な場合、非同期版や軽量版のライブラリを検討してください。

Framer Motion → LazyMotionへ

Motion / Framer Motion は便利ですが、import方法によっては初期バンドルに無視できないサイズが乗ります。公式ドキュメントでは、通常の motion コンポーネントは約34KB、LazyMotionm を使うと初期レンダー時のサイズを約4.6KBまで抑えられると説明されています。

import { LazyMotion, domAnimation, m } from 'framer-motion';

<LazyMotion features={domAnimation}>
  <m.div animate={{ opacity: 1 }}>...</m.div>
</LazyMotion>

個人的には、@next/bundle-analyzerでバンドルサイズを可視化してから最適化する順番がおすすめです。推測で削っても、効果が出ないことがよくあります。

6. use clientを最小限に抑える

'use client'はコンポーネントツリーの境界として機能します。その境界以下のコンポーネントはすべてClientサイドのバンドルに含まれます。親コンポーネントに'use client'を書けば、子コンポーネントが意図せず全員クライアントに引っ張られます。公式の境界の説明は Server and Client Components にあります。

「とりあえずuse clientをつける」を続けると、本来サーバーコンポーネントとして静的に処理できる範囲がどんどん削られていきます。

Client Islands パターン

基本の考え方は、「枠組みはサーバーコンポーネント、動的な処理が必要な箇所だけClient Islandとして切り出す」です。

// page.tsx(Server Component)
import { LikeButton } from '@/components/LikeButton'; // use client

export default async function Page() {
  const post = await getPost(); // サーバーでデータ取得
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      {/* ユーザーのインタラクションが必要な箇所だけClientに */}
      <LikeButton postId={post.id} />
    </article>
  );
}

LikeButtonはインタラクティブなのでuse clientが必要ですが、記事のタイトルや本文はサーバーで静的にレンダリングできます。<article>全体をuse clientにする必要はありません。

Server Component / Client Componentの比較

Server ComponentClient Component(use client
実行場所サーバーブラウザ
バンドルへの影響なしあり(JSバンドルに含まれる)
インタラクション不可
SEOへの影響HTMLとして完全に返るJS実行後にHTMLが完成
向いているケース記事本文、静的コンテンツボタン、フォーム、状態管理が必要な部品

SEOへの影響

サーバーコンポーネントとして処理された部分は、HTMLとして完全に返ってきます。検索エンジンからは静的なHTMLとして見える状態です。

クライアントコンポーネントに落とした部分は、JavaScriptが実行されるまで完全なHTMLになりません。GooglebotはJavaScriptのレンダリングに対応していますが、クロール後にレンダリングキューを経て処理されます。そのため、重要な本文・タイトル・内部リンクなどをクライアントJS実行後にしか生成しない構成では、発見や評価が遅れたり、期待通りに解釈されないリスクがあります。

use clientを絞ると、初期バンドルが小さくなり、検索エンジンに渡すHTMLも揃いやすくなります。

7. 番外: Vercel Function Regionsを日本向けに設定する

Next.jsをVercelにデプロイしていて、動的処理(APIルート・SSR・サーバーサイドのデータ取得)がある場合は要注意です。Function Regions(関数の実行リージョン)の設定を確認してください。

デフォルトは北米(iad1)

Vercelの動的処理(Serverless Functions)は、デフォルトでiad1(バージニア州ダレス、ワシントンD.C.近郊)のリージョンで動きます。

日本向けにサービスを提供している場合、1リクエストごとに太平洋を往復します。体感レイテンシで100〜300msほどの差が出ます。

静的配信は関係ない

CDNのエッジサーバーへキャッシュされた静的コンテンツは全世界のエッジサーバーから配信されるため、Function Regionsの設定は関係ありません。

影響を受けるのは、毎回オリジンサーバーに問い合わせが必要な動的コンテンツのみです。SSRページ、APIルート、cache: 'no-store'なデータフェッチなどが対象です。

hnd1(東京)へ変更する方法

vercel.jsonに以下を追記します。

{
  "regions": ["hnd1"]
}

または、Vercelダッシュボードの Settings > Functions > Function Region から変更できます。

ちなみに、CloudflareはWorkers実行場所の自動最適化(ユーザーに一番近いエッジで実行)が標準になっています。このあたりの設定が要らないのは、さすがCloudflareだなと思いました。

8. まとめ

設計で見る点は、次のとおりです。

  • SSR/SSGの使い分け: ページの性質(動的/静的)に合わせてページ単位で選ぶ
  • Hydrationのコスト: HTMLは返っても、JSが実行されるまでインタラクションは止まる。不要なコンポーネントは遅延ロードする
  • テーマ切り替え: cookies()等でサーバー判定するとdynamic renderになってCDNキャッシュが壊れる。CSS変数かTailwindで管理する
  • next/image: 動的に見えてもURL設計がimmutableなので、静的配信と同等のキャッシュ効率を持つ
  • 初期バンドル: 重いライブラリはバンドルに載る。bundle-analyzerで可視化し、LazyMotion等で必要な機能のみロードする
  • アニメーション: CSSアニメーション(transform/opacity)をCompositorスレッドへ逃がす。JS駆動のアニメーションはメインスレッドを占有する
  • use clientの範囲: 最小限のClient Islandに絞ることでバンドルサイズとSEOの両方を改善できる
  • Vercel Regions: 日本向けサービスならhnd1に手動で変更する

「Next.jsを使えば速い」は、前提から外します。「どこで何がボトルネックになっているか」を設計の段階で押さえておかないと、Lighthouseのスコアはあとから上げにくくなります。この整理を曖昧にしたまま進めると、実装後の改修で手戻りが増えます。

見る軸は、HTMLをいつ作るか、JavaScriptをいつ実行するか、キャッシュをどこで壊すか、の3つです。この3つがページごとに揃っていれば、Next.jsでも体感は速くなります。揃っていなければ、フレームワークを替えても同じ場所で遅くなります。

表示速度の話とは別に、失敗したあとの立て直しもUXの一部です。そちらの設計観点はエラー処理を立て直し設計として整理した記事にまとめています。