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

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

要約を表すAIアイコンこの記事のポイント

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

この記事では、SSRとSSGの違いや、Next.jsのパフォーマンスに関する勘違い、テーマ切り替えの落とし穴などを解説しています。

  • SSRとSSGの特性と使い分けを説明しています。
  • Next.jsの初期表示が遅延する原因と、それを回避するための設計手法を提示しています。
  • テーマ切り替えの実装で発生するHydration flashとその対策を解説しています。

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

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

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

1.SSRとSSGの違い

まず基本の用語から整理しておきます。

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に切り替えます。

静的なページは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は、リクエスト元のデバイスやブラウザに合わせて最適化された画像を返してくれるコンポーネントです。「リクエスト内容に応じて配信が変わる」と聞くと動的配信のように見えますが、実態は違います。

URLが決定論的

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

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

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

同じデバイス解像度のユーザーが同じ画像にアクセスすれば、同じURLを叩きます。URLが同じならレスポンスは使い回せます。要するにimmutable、変わらないという扱いです。

「動的処理をしているのにimmutable」——この逆説が、next/imageを理解するうえでの核心です。

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

next/image は、画像URL・幅・品質などのパラメータに基づいて最適化画像を生成し、キャッシュ可能な形で配信します。ただし、キャッシュ期間や immutable の扱いは、Static Image 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'を書けば、子コンポーネントが意図せず全員クライアントに引っ張られます。

「とりあえず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を絞るだけで、バンドルサイズとSEOの両方に効いてきます。

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のスコアは大きく変わります。逆にこの整理を曖昧にしたまま進めると、実装後のリファクタリングで確実に手戻りになります。