2025/12/03

テクノロジー

Next.jsのSSR Streamingとは?導入事例と共にご紹介!

この記事の目次

    本記事は【Advent Calendar 2025】の3日目の記事です。

    はじめに

    ジョブサーチのWeb版の開発を担当しておりますK.Kです。
    今回は、ジョブサーチにおいてSSR Streamingを導入したので、SSR Streaming自体の解説と、導入してどのような効果があったのかお話できればと思います。

    ジョブサーチでは求人一覧ページにおいて、求人検索や内部リンクを生成するといったような重たい処理をしている箇所があり、全てのデータ取得を待つとページ描画の開始が非常に遅くなってしまうという課題がありました。
    それを改善するため、まずはファーストビューに当たるデータを返し、その後、残りのデータを読み込むという手法を検討しました。

    しかし、
    ジョブサーチはオーガニック流入が大事、、、
    CSRにすると検索エンジンに読み取ってもらえないかも、、、

    ということで、
    検索エンジンに読み取ってもらえそう & 部分的にデータを返せるSSR Streamingを導入しました!

    SSR Streamingとは?

    SSR Streamingは、Next.js 13のApp Routerで導入されました。
    従来のSSRでは、サーバーがHTMLを完全に生成してからクライアントに送信していましたが、SSR Streamingでは、HTMLを部分的にストリーミングしながらクライアントに送信できます。
    この技術により、ユーザーは完全なページの読み込みを待つことなく、コンテンツを段階的に表示できるため、体感的なパフォーマンスが大幅に向上します。

    ジョブサーチでのSSR Streaming導入箇所

    ジョブサーチにおいては、求人一覧画面において、SSR Streamingを導入しています。
    画像のように、最初の2求人のみをレンダリングした状態でまずはHTMLを返し、その後、内部リンクや他の求人も返すという流れになっています。

    Next.jsのレンダリング手法

    さて、これからSSR Streamingについて解説していこうと思います、、、が、
    その前に、そもそもNext.jsにはどんなレンダリング方法があるのでしょうか?
    Next.jsには複数のレンダリング手法があり、今回取り上げているSSRもその一つです。
    まずはSSRが何かを知るために、他のレンダリング方法も含めて解説します!

    SSR(Server-Side Rendering)

    SSRは、サーバー側でReactコンポーネントをHTMLに変換してクライアントに送信する技術です。

    export default async function SSRPage() {
      const data = await fetchData();
      return <div>{data.content}</div>;
    }

    SSRの特徴

    • 初期表示が高速(完成されたHTMLを送信)
    • SEOに有利(検索エンジンがコンテンツを認識可能)
    • サーバー負荷が高い(リクエストごとにHTML生成)
    • 動的なコンテンツに適している

    CSR(Client-Side Rendering)

    CSRは、ブラウザ上でJavaScriptを実行してコンテンツを生成する手法です。初期HTMLは最小限で、データ取得と描画はクライアントサイド(ブラウザ)で行われます。

    "use client";
    
    import { useEffect, useState } from 'react';
    
    export default function CSRPage() {
      const [data, setData] = useState(null);
      
      useEffect(() => {
        fetchData().then(setData);
      }, []);
      
      if (!data) return <div>読み込み中...</div>;
      return <div>{data.content}</div>;
    }

    CSRの特徴

    • 初期表示が遅い(JavaScriptの読み込み・実行が必要)
    • SEOに不利(初期HTMLにコンテンツが含まれない)
    • インタラクティブな操作が高速
    • サーバー負荷が軽い

    SSG(Static Site Generation)

    SSGは、ビルド時に静的なHTMLファイルを事前生成する手法です。

    // 静的生成
    export default async function SSGPage() {
      const data = await fetchStaticData();
      return <div>{data.content}</div>;
    }
    
    // 動的ルートでの静的生成
    export async function generateStaticParams() {
      const posts = await getPosts();
      return posts.map((post) => ({ slug: post.slug }));
    }

    SSGの特徴

    • 最高の表示速度(事前生成されたHTML)
    • SEOに最適(静的コンテンツ)
    • サーバー負荷が最小(静的ファイル配信)
    • 更新頻度の低いコンテンツに適している

    ISR(Incremental Static Regeneration)

    ISRは、SSGの利点を保ちながら、指定した間隔、または、任意のタイミングでコンテンツを再生成する手法です。

    export default async function ISRPage() {
      const data = await fetchData();
      return <div>{data.content}</div>;
    }
    
    // 60秒ごとに再生成
    export const revalidate = 60;

    ISRの特徴

    • 高速な表示速度(静的ファイルを配信)
    • SEOに有利(静的コンテンツ)
    • サーバー負荷が軽い(定期的な再生成のみ)
    • 動的コンテンツと静的配信の両立が可能

    手法の比較

    手法初期表示SEOサーバー負荷適用場面
    CSR遅い不利軽いSPA、管理画面
    SSR高速有利重い動的コンテンツ
    SSG最高速最適最軽ブログ、LP
    ISR高速有利軽いECサイト、ニュース

    SSR Streamingの解説

    さて、ここまでで、各レンダリング手法の仕組みや、メリット・デメリットがお分かりいただけたかと思います。
    私が担当しているプロジェクトのジョブサーチでは、これまで単にSSRを使用していました。
    それは、動的なコンテンツであり、かつ、SEOが大事なサイトだからです。
    しかし、今回お話ししているSSR Streamingでは、段階的に表示をさせながら、検索エンジンにコンテンツを読み取らせることができます。

    HTTPストリーミング

    SSR Streamingを理解するには、HTTPの仕様が鍵となります。
    単なるSSRのHTTPレスポンスでは、サーバーは完全なレスポンスを生成してからクライアントに送信していました。

    // 従来のHTTPレスポンス
    HTTP/1.1 200 OK
    Content-Type: text/html
    Content-Length: 1024
    
    <!DOCTYPE html>
    <html>...</html>

    HTTP/1.1ではTransfer-Encoding: chunked​ヘッダーを使用することで、1回のHTTPレスポンスを複数のチャンクに分割して送信できます。
    よって、CSRのようにHTMLの読み込みが完了した後に別の情報を取得するのではなく、1回のHTTPレスポンス内で遅延させた情報も読み込むことが可能になります!

    // ストリーミングHTTPレスポンス
    HTTP/1.1 200 OK
    Content-Type: text/html
    Transfer-Encoding: chunked
    
    // チャンク1
    1A
    <!DOCTYPE html><html><head>
    
    // チャンク2
    2F
    <title>Page Title</title></head><body>
    
    // チャンク3
    15
    <h1>Hello World</h1>
    
    // チャンク4
    E
    </body></html>
    
    // 終了チャンク
    0

    Next.jsでのStreaming実装

    では、これをどうやって実装したら良いのでしょうか?
    Next.js 13のApp Routerでは、簡単に実装することができます!
    Suspenseを使用することで、Suspense内部のコンポーネントは描画が完了した後に配信され、ブラウザは受信したHTMLチャンクを段階的に解析・表示します。
    また、データが送られて来るまでの間は、fallbackの内容を表示しておくことができます。

    import { Suspense } from 'react';
    
    export default function StreamingPage() {
      return (
        <div>
          {/* 即座に送信される部分 */}
          <h1>即座に表示される部分</h1>
          
          {/* 非同期で読み込まれる部分 */}
          <Suspense fallback={<div>読み込み中...</div>}>
            <SlowComponent />
          </Suspense>
          
          {/* 複数の非同期コンポーネント */}
          <Suspense fallback={<div>データ取得中...</div>}>
            <DataComponent />
          </Suspense>
        </div>
      );
    }
    
    async function SlowComponent() {
      await new Promise(resolve => setTimeout(resolve, 2000));
      return <div>時間のかかる処理の結果</div>;
    }
    
    async function DataComponent() {
      const data = await fetch('https://api.example.com/data');
      return <div>{data.content}</div>;
    }

    Suspenseで囲んでfallbackで指定するだけで後からHTMLを書き換えてくれるのは非常に便利ですね!

    ジョブサーチでの実装例

    ジョブサーチでは、最初の2件の求人のみを返し、その下の求人や内部リンクはfallbackにスケルトンを指定しています。

    export const SearchPage = async ({ searchParams }) => {
     // 初期表示するための求人情報を取得
      const initialData = await getInitialSearchResults(searchParams);
    
      return (
        <div>
          {/* 即座に表示される初期結果 */}
          <InitialResultsList data={initialData} />
    
          {/* 追加の求人を非同期で読み込み */}
          <Suspense fallback={<LoadingSkeleton />}>
            <AdditionalResultsWrapper searchParams={searchParams} />
          </Suspense>
        </div>
      );
    };

    以下の部分で残りのデータを取得しています。

    const AdditionalResultsWrapper = async ({ searchParams }) => {
     // 時間のかかるデータ取得処理
      const additionalData = await getAdditionalResults(searchParams);
      const relatedData = await getRelatedContent(searchParams);
    
      return (
        <>
          <AdditionalResultsList data={additionalData} />
          <Pagination totalCount={additionalData.count} />
          <RelatedContent data={relatedData} />
        </>
      );
    };

    スケルトンには実際のコンテンツと同じような大きさを持つようにして実装しています。

    const LoadingSkeleton = () => {
      return (
        <div>
          {Array.from({ length: 10 }).map((_, i) => (
            <div key={i} className="skeleton-item">
              <div className="skeleton-title" />
              <div className="skeleton-description" />
            </div>
          ))}
        </div>
      );
    };

    これにより、以下の画像のように段階的にページを表示させることができます。

    導入結果

    ページ表示速度

    さて、今回の変更で、ページ表示は早くなったのでしょうか?
    結果は、、、体感めっちゃ早くなりました!
    が、サーチコンソールの平均応答時間に変化はありませんでした。

    いくつかの条件で調べた結果、おおよそ1秒以内でHTMLが返ってきていました。
    会社名:株式会社マイナビ:0.35秒
    エリア:埼玉県 熊谷市:0.45秒
    フリーワード:ITエンジニア:0.98秒

    これまでは2~3秒程度かかっていたので、だいぶ時間が短縮されています。

    一方で、サーチコンソールでの平均応答時間は、以前として大きな値のままになっています。
    おそらく、HTMLの読み込みが終わるタイミングを平均応答時間として表現しているのではないかと思います。

    検索エンジンでの認識

    サーチコンソール上でURL検査を見たところ、後から読み込ませている部分もHTMLとして認識されていることがわかりました。
    これについては、Vercelの方でも言及されている記事がありました。参考記事

    まとめ

    今回の変更を行ったことで、ユーザー体験に大きな向上があったと考えています。
    検索エンジンにコンテンツを認識させながらも、ファーストビューの表示をとにかく早くすることができました!
    SEO対策として検索エンジンに読み取ってもらうコンテンツを作成するためにページ表示速度が犠牲になっているサイトには、SSR Streamingはユーザー体験向上に非常に効果的な施策だと思います。

    イベント告知

    12月23日にイベントを開催します!申し込みはこちらから▼

    https://mynaviit.connpass.com/event/376769

    ※本記事は2025年12月時点の情報です。

    著者:マイナビエンジニアブログ編集部