0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

サーバーが速くても Next.js の画面遷移が遅く感じる理由

0
Posted at

Next.js を使い始めてしばらくは、リンクを押したあとに画面が一瞬そのままになる理由を説明できませんでした。

サーバーで特別に重い処理をしているわけではありません。ブラウザーには JavaScript も届いています。それでも、次の画面を出すまでに通信の往復を待っていました。

足りなかったのは、遷移先の React Server Components の出力、つまり RSC ペイロードでした。Next.js がそれをいつ取得し、どこまで再利用できるのかを理解すると、遅延の原因も対処方法も見えやすくなりました。

この記事は、私が書いている Next.js のパフォーマンス連載の第1回です。App Router の従来のルート単位のキャッシュと、Next.js 16.3 以降で利用できる cacheComponents、partialPrefetching を扱います。

ブラウザーには JavaScript だけでは足りない

クライアント側で描画するアプリなら、必要な JavaScript があれば、ブラウザーは遷移先の画面を組み立てられます。データ取得が終わっていなくても、アプリ側で用意したローディング表示を先に出せます。

Server Components を使う場合は、ブラウザーが持っているものが変わります。コンポーネントの実装はサーバーに残り、その出力を RSC ペイロードとして受け取ります。ペイロードには、Client Components への参照や、それらに渡す props も含まれます。

これはコンポーネントツリーをシリアライズした表現です。HTML ドキュメントそのものでも、Server Components のソースコードでもありません。

たとえば、次のページを考えます。

// app/posts/page.tsx
import { LikeButton } from './like-button'

export default function PostsPage() {
  return (
    <main>
      <h1>記事一覧</h1>
      <LikeButton />
    </main>
  )
}
// app/posts/like-button.tsx
'use client'

import { useState } from 'react'

export function LikeButton() {
  const [liked, setLiked] = useState(false)
  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? 'いいね済み' : 'いいね'}
    </button>
  )
}

PostsPage は async でもなく、データベースにもアクセスしていません。それでも Server Component です。App Router のページとレイアウトは、デフォルトで Server Components です。'use client' がクライアント側の境界を作り、その境界から import するコンポーネントもクライアント側のモジュールグラフに入ります。

画面遷移を考えるときは、ブラウザーが持つツリーの一部に、サーバーの出力が必要な空欄があると考えると分かりやすいと思います。大事なのは、その空欄を埋める情報がクリックの前に届くか、後に届くかです。

必要なペイロードがなければ、遷移はサーバーの応答を待つことがあります。ただし、プリフェッチ済みのローディング境界や静的なシェルがあれば、それを先に表示できます。Server Component があるというだけで、すべての遷移が遅くなるわけではありません。

Link のプリフェッチは何を先に取得するのか

Next.js は、ユーザーが遷移する前にルートのデータを取得できます。本番環境では、通常、<Link> がビューポートに入るとプリフェッチが始まります。クリックしたときには、取得済みのペイロードを再利用できます。

自動プリフェッチは開発環境では無効です。next dev の Network タブだけを見て、本番の動きを判断しないようにしています。

Partial Prefetching を有効にする前のデフォルト動作は、次のように分かれます。

遷移先 デフォルトのプリフェッチ
静的にレンダリングするルート ルート全体を取得
動的にレンダリングするルート loading.js があれば、その境界までを取得

prefetch={true} を指定すると、従来のモデルでは動的なルートも含めて全体をプリフェッチできます。クリック後の待ち時間を減らせる一方、実際には開かないリンクについても、事前にサーバーで処理することになります。

Partial Prefetching を有効にすると、デフォルトでは共有できる App Shell を取得する方式に変わります。Next.js の Link リファレンス

基本的な流れはこうです。

リンクが表示される
  → 再利用できるルートの内容を取得
  → ブラウザーの Client Cache に保存

クリック
  → キャッシュした内容を表示
  → 足りない部分を取得

サーバーの処理を数ミリ秒短くするより、クリック前に必要な情報を取得しておく方が、体感に効くことがあります。

動的セグメントと動的レンダリングは別の話

ここで混乱しやすいのが「動的」という言葉です。

posts/[slug] のようなディレクトリは、URL の一部を変数として扱う動的セグメントです。これだけで、毎回リクエスト時にレンダリングすることが決まるわけではありません。generateStaticParams で値を指定して、事前に生成できる場合もあります。

一方、動的レンダリングは、リクエスト時にレンダリングすることです。従来のキャッシュモデルでは、cookies() や headers() のようなリクエスト固有の値を読むと、そのルートが動的レンダリングになります。ツリーの末端で読んだ場合も同じです。

ほとんどが静的な記事ページに、ユーザーごとの小さな操作欄があるとします。

記事ページ
├─ 記事タイトル
├─ 記事本文
└─ ユーザーごとの操作欄
   └─ cookies() を読む

Cache Components などの部分的な事前レンダリングを使わない場合、この読み取りがルートのレンダリング方式に影響します。タイトルや本文の描画が軽くても、全員に同じ静的レスポンスを返すルートではなくなります。

ただし、キャッシュ済みのデータや共有レイアウトは再利用できます。「動的になったらすべてのキャッシュが消える」と理解すると、別の誤解につながります。Next.js の従来のキャッシュモデル

Suspense の fallback もブラウザーに届く必要がある

ユーザーごとの操作欄を <Suspense> で囲めば、その処理が終わる前に、サーバーは周囲の内容と fallback をストリーミングできます。

ただ、従来のモデルでは、動的なページの中に Suspense を置くだけで、その外側が自動的にビルド時の静的シェルになるわけではありません。ブラウザーは、最初の応答を受け取るまで、何を表示すればよいか分からないことがあります。

クリック
  → 遷移先のペイロードを要求
  → 最初の応答が届く
  → 周囲の内容と fallback を表示
  → 残りの内容がストリーミングで届く

プリフェッチした loading.tsx の境界なら、初期表示をすぐに切り替えられます。しかし、まだ取得していない遷移先のペイロードの中にしかない fallback は、届くまで使えません。

「Suspense は使っているのに、なぜ遷移で待つのか」と考えていたとき、私が見落としていたのはこの違いでした。Next.js の即時ナビゲーションガイド

通信距離が長いと、この差ははっきり出ます。以前 Cloudflare でサービスを運用していた際、韓国からのアクセスがロサンゼルスや香港に届くことがありました。サーバーで素早くレンダリングしても、その往復自体はなくなりませんでした。

staleTimes はページの再利用条件として読む

実験的な設定である staleTimes は、Client Cache 内のページセグメントの再利用期間を指定します。

// 従来のキャッシュモデルでのデフォルト値
const nextConfig = {
  experimental: {
    staleTimes: {
      dynamic: 0,
      static: 300,
    },
  },
}

export default nextConfig

設定名をそのまま受け取ると、少し間違えやすいところです。dynamic が対象にするのは、静的に生成しておらず、全体のプリフェッチもしていないページです。全体をプリフェッチしたページには、static の期間が適用されます。

ローディング境界も static の期間で再利用でき、共有レイアウトやブラウザーの戻る・進むには、それぞれの再利用の仕組みがあります。

つまり dynamic: 0 は、「動的なルートの情報を Next.js は一切保持しない」という意味ではありません。通常の再訪時に、ページセグメントを取り直す理由の一つです。このデフォルト値は Next.js 15 で30秒から0秒に変わりました。staleTimes のリファレンス

Cache Components で再利用できるシェルを作る

Cache Components を使うと、リクエスト固有の内容を Suspense の内側に残しながら、外側のシェルを事前にレンダリングできます。

// next.config.ts
const nextConfig = {
  cacheComponents: true,
}

export default nextConfig

これによって、遷移の流れが変わります。

クリック前
  → 静的シェルと fallback をプリフェッチ

クリック後
  → シェルをすぐに表示
  → リクエスト固有の内容をストリーミングで埋める

私は今、リクエスト時の情報が必要な部分に境界を置くようにしています。ページ全体が待つ必要があるとは、最初から決めつけません。

このフラグを有効にすると、キャッシュの指定方法も変わります。再利用する処理には 'use cache'、キャッシュせずにストリーミングする処理には適切な Suspense 境界を用意します。既存アプリでは、設定を1行追加するだけでは済まないこともあります。Next.js のキャッシュガイド

私も移行で苦労しました。Next.js 16.2 と Cloudflare/OpenNext の組み合わせでは、ミドルウェアとの互換性、レイアウトでの認証情報の読み取り、画面遷移の挙動を調べながら、このオプションを何度も有効・無効に切り替えていました。最終的にはホスティングを変えてから有効にしました。単に速度を上げるスイッチではなく、アプリの移行として扱う方がよいと感じた経験です。

ライフサイクルにも注意点があります。Next.js は React Activity を使って、最近開いた画面を保持することがあります。Activity は DOM を隠して state を保持しつつ、Effect をクリーンアップします。「別のルートに移動したら DOM も破棄する」という前提で書いた処理は、確認が必要です。動画など、DOM 自体に動作が結びついている要素では特に気になります。React Activity のリファレンス

Partial Prefetching で複数の URL がシェルを共有する

Next.js 16.3 では、もう一つのオプションが加わりました。

const nextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
}

export default nextConfig

記事一覧には /posts/1、/posts/2、/posts/3 へのリンクがあります。記事の内容は違っても、画面の構造は共通にできます。

Partial Prefetching は、同じルートパターンへのリンクで App Shell を再利用する仕組みです。URL ごとに全体を独立したプリフェッチ対象として扱う必要を減らします。Next.js 16.3 の発表

ここで効くのが、URL 固有の情報をどこで読むかです。ページの先頭で params を await すると、その URL に依存する範囲が広がります。Promise のまま子に渡し、Suspense の内側で読むと、URL に依存しない UI を共有シェルに残しやすくなります。

デフォルトではシェルを先に取得し、URL ごとの内容は遷移後にストリーミングできます。特定のリンクでより積極的に取得したければ、prefetch={true} で URL に依存するキャッシュ済みの内容もプリフェッチできます。事前の処理量と、クリック後の待ち時間との兼ね合いです。Partial Prefetching の導入ガイド

サービスワーカーのキャッシュは別に設計する

私のサービスでは、Serwist を使い、ブラウザーのメモリー上のキャッシュより長く、一部のリソースを保存しています。最初の候補は静的アセットです。

一方、RSC レスポンスの永続化は慎重に扱っています。レスポンスの内容は、リクエストヘッダー、ルートの状態、デプロイ、認証などによって変わる可能性があります。

元の記事で紹介した実装は、Next.js のレスポンスヘッダーを見て分類し、独自のキャッシュルールを適用していました。ただ、そのコードをどのアプリでも使えるレシピとしては扱いません。_rsc をキャッシュキーから外したり、個人向けのレスポンスを必要な条件の区別なしに保存したりすると、別のレスポンスを再利用してしまうおそれがあります。

まずフレームワークのプリフェッチと App Shell を整え、そのあとオフライン対応や永続化の具体的な用途がある場合に、サービスワーカーを追加する順番がよさそうです。Next.js の CDN キャッシュガイド、Serwist

一覧には詳細画面の情報がすでにある

最後の改善は、画面設計の話です。

一覧から詳細を開く場合、ブラウザーはすでにタイトル、サムネイル、概要を知っています。それをすべて消して画面全体をローディング表示にすると、せっかくある情報を使えません。

私のサービスのショーケースでは、Intercepting Routes を使って、同じ URL への入り方を分けています。

入り方 最初に表示するもの
一覧からの遷移 保存済みの概要を先に表示し、残りの詳細を取得
URL への直接アクセスや再読み込み 通常のサーバールートで詳細をレンダリング

インターセプト側は、一覧で使っているクライアントの状態を初期表示に利用します。同じ ID の概要がなければ、スケルトンにフォールバックします。通常のルートは、直接アクセスした場合のサーバーでのデータ取得とメタデータを担当します。

Intercepting Routes が入り方を区別し、共有するクライアントの状態が即時表示の材料になります。Intercepting Routes だけで、RSC リクエストが一切ない遷移を保証するわけではありません。Next.js の Intercepting Routes

クリック後に何を待っているかを見る

今も Next.js を便利に使っています。ここで書いた改善も、自分が開発している Comwit の画面遷移をよくしたくて取り組んだものです。

遷移が遅いと感じたら、私はまず、ブラウザーがすでに持っているもの、サーバーから追加で必要なもの、その通信中に表示できるものを確認します。

Server Component があること自体より、次の画面に必要な UI をクリックしてから取得し始めていないか。最初に見るのはそこです。


2026年9月27日に公開した韓国語の元記事をもとに、日本語向けに整理しました。

連絡先: X @meursyphus

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?