⏱️

Next.js(App Router)でISRを使用する際の基本的な構成、レンダリングやキャッシュに関する設定と注意点

2024/07/29に公開

App RouterでISRを実装したけれど、十分にキャッシュが作られていない問題が起こったことがありました。具体的にはVercelのUsageにあるData CacheでIncremental Static Regenerationの割合が少なく、Vercel Data Cacheの割合がかなり多くなっていたことです。

原因はNext.jsにおけるISRの要件を満たせていなかったこと。
公式ドキュメントを改めて読み返してみれば書いてあったのですが、問題が起こらないと気づけないことも多いなと実感しました。

この記事ではNext.jsのApp RouterでISRを実装する際の基本的な構成、SGやSSRといった他のレンダリング方法との設定の違い、そして特に気をつけておいたほうがいい点をまとめてみました。

キャッシュに関する詳細は載せていませんが、よくあるISRの実装要件の助けにはなるのではないかと思います。

Next.jsは14.2.4、ホスティングはVercelで動作の確認をしています。

要約

  • Server Component内でフェッチをして、revalidateを設定すればISRになる
  • revalidateは3箇所で記述ができ、上書きされたり、独立して動作する
  • generateStaticParamsで事前にビルドができるが、設定を上書きしていなければページにアクセスした時点でもISRとして動作する
  • On-demand Revalidationでキャッシュの更新をコントロールできると、revalidate時間を長く設定でき、パフォーマンスやコストの削減に貢献できる
  • stale-while-revalidate(SWR)というキャッシュ戦略を知っておく
  • export const revalidateでは掛け算をせず600のような数値を設定しておく
  • Route Segment Configのオプションを把握し、適切に上書きをする

Next.js(App Router)でISRを指定する場合の最小構成

まずはApp RouterでISRを使用する際の基本的なテンプレートです(サンプルなので大枠だけ確認してください)。

app/posts/[id]/page.js
import { Metadata } from 'next';

export const revalidate = 600; // 10分ごとに再検証する

type Post = {
};

type Props {
  params: { id: string }
};

// 動的ルートのパラメータを生成して、デプロイ時にファイルを生成する
export async function generateStaticParams() {
  const res = await fetch('https://api.example.com/posts');
  const posts: Post[] = await res.json();
  
  return posts.map((post) => ({
    id: post.id.toString(),
  }));
}

export async function generateMetadata({
  params,
}: {
  params: { id: string };
}): Promise<Metadata> {
  const res = await fetch('https://api.example.com/posts/${id}');
  const posts: Post[] = await res.json();

  return {
    // メタデータ
  };
}

// ページコンポーネント
export default async function Post({ params }: Props) {
  const { id } = params;
  const res = await fetch(`https://api.example.com/posts/${id}`, { next: { revalidate } });
  const post: Post = await res.json();

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}
  • export default async function PostのようにServer Component内でフェッチを記述すると、SGの挙動になります
    • App Routerでは変更をしない限り、なるべくキャッシュを保持する挙動になります
      • By default, Next.js will cache as much as possible to improve performance and reduce cost. This means routes are statically rendered and data requests are cached unless you opt out.
        デフォルトでは、Next.jsはパフォーマンス向上とコスト削減のため、可能な限りキャッシュします。つまり、オプトアウトしない限り、ルートは静的にレンダリングされ、データリクエストはキャッシュされます。
        Building Your Application: Caching | Next.js

  • revalidate(再検証時間)を2箇所に記述していますが、これによってTime-based Revalidationを設定し、SG+一定時間でキャッシュを作成し直すISRの挙動になります
  • revalidateは3つの方法で記述可能で、組み合わせによって設定が上書きされたり、個別に動作したりします(詳しくは後述します)
    • Server Componentのファイル内でexport const revalidate = 600;のように指定する(ページ単位で指定したい場合)
    • Server Componentに適用されるlayout.js内でexport const revalidate = 600;のように指定する(セグメント単位で指定したい場合)
    • Server Componentのフェッチ内でfetch(URL, { next: { revalidate: 600 } })のように指定する(フェッチ単位で指定したい場合)
  • export async function generateStaticParams()を記述してデプロイ時に静的なリソースを準備することができます

ISRを事前にビルドするgenerateStaticParamsは必要か?

静的なリソースを事前に用意できませんが、generateStaticParamsを記述しなくてもISRの挙動になります。
dynamicParamsの初期値によって、事前にビルドしていないページでもアクセスした時点でISRの対象になるからです。

The generateStaticParams function can be used in combination with dynamic route segments to statically generate routes at build time instead of on-demand at request time.
generateStaticParams 関数を動的なルートセグメントと組み合わせて使うことで、リクエスト時にオンデマンドでルートを生成するのではなく、ビルド時に静的にルートを生成することができます。

Functions: generateStaticParams | Next.js

dynamicParams
Control what happens when a dynamic segment is visited that was not generated with generateStaticParams.
generateStaticParamsで生成されなかったダイナミックセグメントにアクセスしたときの動作を制御します。

export const dynamicParams = true // true | false,
  • true (default): Dynamic segments not included in generateStaticParams are generated on demand.
    true (デフォルト): generateStaticParams に含まれない動的セグメントは、オンデマンドで生成されます。
  • false: Dynamic segments not included in generateStaticParams will return a 404.
    false: generateStaticParams に含まれていない動的セグメントは 404 を返します。

File Conventions: Route Segment Config | Next.js

generateStaticParamsの利点は、事前に静的なリソースを用意することで、最初のアクセスから高速に表示できることです。
対象のページ数やデプロイ頻度にもよりますが、基本的にはgenerateStaticParamsを使用しておき、事前にビルドをするコストが問題になってくるようならアクセスの多いページや更新日の新しいページなどを優先して適用していくのがいいのではないかと考えています。

ページが1万以上で毎日更新されるようなサイトや、Search Consoleでインデックスの未登録が起きているサイトでは、効率的にクロールしてもらうための方法として検討してもいいかもしれません。

大規模サイトのクロール バジェット管理 | Google 検索セントラル  |  ドキュメント  |  Google for Developers

任意のタイミングでキャッシュを更新するにはrevalidatePathとrevalidateTagを使用する

通常のISRはTime-based Revalidationで一定のタイミングでしか再検証されませんが、On-demand Revalidationを使うとタイミングをコントロールできるようになります。

それがrevalidatePathとrevalidateTagです。

  • revalidatePath
    • 指定したパス(/posts/[id]/page.jsのようなファイルシステムパス)に対してrevalidateを実行できます
  • revalidateTag
    • Next.js 13.4以降で指定可能になったフェッチのオプションであるtagsfetch(url, { next: { tags: [...] } });)に対してrevalidateを実行できます

ページ単位でrevalidatePathを使ったことがありますが、動的ルート単位でも設定ができます。
On-demand Revalidationによって特定のタイミングでキャッシュをコントロールできるようになると、Time-based Revalidationで指定するrevalidateを長く設定できるようになり、キャッシュがヒットしやすくなります。サイトを高速化できるだけでなく、ホスティングサービスで利用料金を抑えることにも寄与できます。

SGではなくISRを選択するということは、ビルド時間の短縮やデータの鮮度を保つ必要があるということだと思います。そういった意味でもOn-demand Revalidationをどのように活用するかを設計段階で検討しておきたいところですね。

トリガーとしては次のようなものが考えられそうです。

  • 画面上でボタンをクリックしたとき
  • 何らかの処理でwebhookが実行されたとき(外部サービス)
  • GitHub Actionsが実行されたとき(リリースブランチが更新された、定期実行、GitHub APIを通してなど)
  • VercelのCron Jobsで定期実行されたとき

VercelのCronジョブはProで40回、Enterpriseで100回の制限がありますが、1日1回の実行なら制限内に収まります(Functionsは使用されるので、超過すれば課金対象になる)。

Cron jobs invoke Serverless or Edge Functions. This means the same usage and pricing limits will apply.
CronジョブはServerlessまたはEdge Functionsを呼び出します。これは、同じ使用量と価格制限が適用されることを意味します。
Usage & Pricing for Cron Jobs

Next.js(App Router)でISRを指定する場合に気をつけておくこと

ISRの基本的な記述方法を知れたところで、ここからはISRの動作検証をしたり、正常に動作していないときに確認しておくことになります。

Next.jsのISRはstale-while-revalidate(SWR)ライクになっている

ISRが正常に動作しているか検証する際に、次のリンクにある「How Time-based Revalidation Works」を確認しておきます。

his is similar to stale-while-revalidate behavior.
これはstale-while-revalidateの動作に似ている。

Building Your Application: Caching | Next.js

Time-based Revalidationのシーケンス図

すごくざっくりまとめると、次のような挙動になります。

  1. キャッシュがない(MISS)
    • 動的にページを生成する
    • バックグラウンドで新しいキャッシュを作成する
  2. キャッシュがありrevalidateの時間内
    • キャッシュで表示する(HIT)
  3. キャッシュはあるがrevalidateを過ぎている
    • 古いキャッシュで表示する(STALE)
    • バックグラウンドで新しいキャッシュを作成する

注意しておくのが、キャッシュはバックグラウンドで更新され、ページにアクセスをしてた時点では古い(STALE)キャッシュで表示する点です。新しいキャッシュ(HIT)は次にアクセスした時点で適用されます。
キャッシュを使いながらできるだけ鮮度の高いリソースを返す戦略のようです。

When a request is made to a path that hasn’t been generated, Next.js will server-render the page on the first request. Future requests will serve the static file from the cache. ISR on Vercel persists the cache globally and handles rollbacks.
生成されていないパスへのリクエストがあると、Next.jsは最初のリクエストでページをサーバーレンダリングします。以降のリクエストはキャッシュから静的ファイルを提供します。VercelのISRはキャッシュをグローバルに永続化し、ロールバックを処理します。

Data Fetching: Incremental Static Regeneration (ISR) | Next.js

次のようにしてISRの挙動をChromeのデベロッパーツールで確認できます。

  1. ネットワークタブを開く
  2. 記事IDを「フィルタ」に入力するか、「ドキュメント」を選択する(項目を絞っておいたほうが確認しやすくなります)
  3. 「名前」の最初に記事IDの項目があるので選択する
  4. 「ヘッダータブ」にある「ステータスコード」「Cache-Control:」「Date:」「X-Vercel-Cache:」などを確認する

VercelだとDeploymentページにあるDeployment Summaryを開くと、ISR Functionsという項目があるので、デプロイ時点でISRの対象になっているページを確認できます。

Next.jsのLinkコンポーネントでprefetchされても再検証されない

Next.js 12.2.0以前はLinkコンポーネントでprefetchされたタイミングでも再検証されていたようですが、2022年6月29日のリリースでページを表示したタイミングで再検証されるようになっています。

Update to not trigger revalidation during prefetch: #37201
プリフェッチ中に再検証をトリガしないように更新: #37201

Release v12.2.0 · vercel/next.js

[Next.js] ISR の prefetch 時にはページの再生成がトリガーされない

export const revalidateは掛け算を使用すると適用されない

The values of the config options currently need be statically analyzable. For example revalidate = 600 is valid, but revalidate = 60 * 10 is not.
現在、設定オプションの値は静的に解析可能である必要がある。例えば、revalidate = 600は有効だが、revalidate = 60 * 10は有効ではない。

File Conventions: Route Segment Config | Next.js

たとえば1日で設定したいからといってrevalidate = 60 * 60 * 24のように記述すると適用されないので、revalidate = 86400のように記述する必要があります。
revalidate以外の設定にもよりますが、SGやSSRでレンダリングされてしまう可能性があるので注意が必要です。

フェッチのrevalidateはセグメントと個別に適用される

従来 ISR はページ単位での制御でしたが、Next.js 13 App Router からは、個々の fetch() リクエスト単位で revalidate 期間が制御可能になりました。

Next.js 13 の cache 周りを理解する - revalidate

上記の記事にある通り、フェッチとセグメント(ページ)のrevalidateは個別に適用されます。

ただ、セグメント(ページ)よりフェッチのrevalidateを長く設定していた場合に、次のような意図しない挙動が起きたことがありました。

  • ステータスコードが200 OKではなく304 Not Modifiedになっている
  • X-Vercel-CacheSTALEHITと正常っぽい動きをしている
  • Dateはキャッシュのあるrevalidate内でリロードしても更新されている
  • ネットワークタブで「キャッシュを無効化」をオンにすると、正常なISRの挙動になっている

どうやらフェッチのrevalidateの期間内でアクセスしたときに「まだ再検証しなくてもいい」とみなされ、ブラウザのキャッシュで表示する挙動になるようです。

ステータスコードが304になる問題についての調査記録

逆に、セグメント(ページ)よりフェッチのrevalidateを短く設定していた場合に、フェッチのrevalidateに引っ張られて再検証が多く実行されてしまう(キャッシュが有効活用できない)状態も起こるかもしれません。

If an individual fetch() request sets a revalidate number lower than the default revalidate of a route, the whole route revalidation interval will be decreased.
個々の fetch() リクエストが、ルートのデフォルトの revalidate よりも低い revalidate 数を設定した場合、ルート全体の revalidate 間隔は短くなります。

Functions: fetch | Next.js

フェッチにrevalidateを設定していない場合は304 Not Modifiedで表示されていました。
この時の挙動は、次のようになるようです。

  • ISRとして動作していて、バックグランドでキャッシュは作成されている
    • ブラウザにキャッシュがあればそれを使い、キャッシュがないユーザーは200 OKで返す
      • ブラウザにキャッシュがある場合もスーパーリロードや、ネットワークタブの「キャッシュを無効化」状態にしてリロードすると200 OKで返される

304 Not Modifiedはブラウザのキャッシュを使っている状態なので、サーバーの負荷やトラフィックの削減を期待できる可能性もあります。
ISRを単純に事故なく使うために、フェッチのrevalidateは使わず、セグメント(ページ)にだけ設定しておくのがいいかもしれません。

セグメント内で最も短い時間のrevalidateが適用される

App Router では、Route Segment Config[2]という仕組みで、レイアウトあるいはページから特定の変数を export すると動作をカスタマイズできます。

複数のレイアウト・ページから revalidate が export されており、かつその値が異なっている場合は、もっとも更新頻度が高くなる値が適用されます。

Next.js 13 の cache 周りを理解する - revalidate

上記の記事にある通り、同じセグメントにあるlayout.tsxやpage.tsxで複数のexport const revalidateを記述した場合は、そのセグメント全体で最も低い数値が適用されます(記事内のGIFアニメがすごくわかりやすいです)。

公式ドキュメントにも次のような記述がありました。

If you have multiple fetch requests in a statically rendered route, and each has a different revalidation frequency. The lowest time will be used for all requests. For dynamically rendered routes, each fetch request will be revalidated independently.
静的にレンダリングされたルートに複数のフェッチリクエストがあり、それぞれに異なる再検証頻度がある場合。最も低い時間がすべてのリクエストに使用されます。動的にレンダリングされるルートの場合、各フェッチリクエストは個別に再検証されます。

Data Fetching: Fetching, Caching, and Revalidating | Next.js

ページ単位で設定したrevalidateが適用されない場合はlayout.tsxのexport const revalidateを確認していくとよさそうです。

export const revalidatefalse0にしない、フェッチのキャッシュオプションを'force-cache''no-store'にしない

export const revalidate = false;cache: 'force-cache はSGの挙動になり、export const revalidate = 0;cache: 'no-store'はSSRの挙動になります。
問題が起きたらexport const revalidatecache:を検索してみてください。

Set the default revalidation time for a layout or page. This option does not override the revalidate value set by individual fetch requests.
レイアウトまたはページのデフォルトの再検証時間を設定します。このオプションは、個々のフェッチ・リクエストによって設定されたrevalidate値を上書きしません。

  • false (default): The default heuristic to cache any fetch requests that set their cache option to 'force-cache' or are discovered before a dynamic function is used. Semantically equivalent to revalidate: Infinity which effectively means the resource should be cached indefinitely. It is still possible for individual fetch requests to use cache: 'no-store' or revalidate: 0 to avoid being cached and make the route dynamically rendered. Or set revalidate to a positive number lower than the route default to increase the revalidation frequency of a route.
    false (デフォルト): デフォルトは、キャッシュオプションを 'force-cache' に設定したフェッチリクエストや、ダイナミック関数が使われる前に発見されたフェッチリクエストをキャッシュするヒューリスティックな方法です。意味的には revalidate: Infinity と同等で、実質的にリソースを無期限にキャッシュすることを意味します。個々のフェッチリクエストで cache: 'no-store' または revalidate: 0 を使用してキャッシュを回避し、ルートを動的にレンダリングすることは可能です。あるいは revalidate をルートのデフォルトより低い正の数に設定することで、ルートの再検証頻度を増やすことができます。
  • 0: Ensure a layout or page is always dynamically rendered even if no dynamic functions or uncached data fetches are discovered. This option changes the default of fetch requests that do not set a cache option to 'no-store' but leaves fetch requests that opt into 'force-cache' or use a positive revalidate as is.
    0: 動的関数やキャッシュされていないデータフェッチが検出されなくても、レイアウトやページが常に動的にレンダリングされるようにします。このオプションは、キャッシュオプションを設定しないフェッチリクエストのデフォルトを 'no-store' に変更しますが、'force-cache' を選ぶか、正の revalidate を使うフェッチリクエストはそのままにします。
  • number: (in seconds) Set the default revalidation frequency of a layout or page to n seconds.
    number : (秒単位) レイアウトまたはページのデフォルト再有効化頻度を n 秒に設定。

File Conventions: Route Segment Config | Next.js

export const dynamic = 'auto'が適用されるようにする

dynamicの初期値はautoで基本的に変更することはないと思いますが、レンダリングなどを強制的に上書きするので注意が必要です。

  • 'auto' (default): The default option to cache as much as possible without preventing any components from opting into dynamic behavior.
    auto」(デフォルト): デフォルトのオプションは、動的な振る舞いを選択するコンポーネントを防止することなく、可能な限りキャッシュします。
  • 'force-dynamic': Force dynamic rendering, which will result in routes being rendered for each user at request time. This option is equivalent to getServerSideProps() in the pages directory.
    force-dynamic': 動的なレンダリングを強制します。その結果、リクエスト時にそれぞれのユーザーに対してルートがレンダリングされます。このオプションはpagesディレクトリのgetServerSideProps()と同等です。
  • 'error': Force static rendering and cache the data of a layout or page by causing an error if any components use dynamic functions or uncached data. This option is equivalent to:
    'error': 静的レンダリングを強制し、動的関数やキャッシュされていないデータを使用しているコンポーネントがあれば、エラーを発生させることで、レイアウトやページのデータをキャッシュします。このオプションは以下と同等です:
    • getStaticProps() in the pages directory.
      ページ・ディレクトリの getStaticProps() を参照してください。
    • Setting the option of every fetch() request in a layout or page to { cache: 'force-cache' }.
      レイアウトまたはページ内のすべてのfetch()リクエストのオプションを{ cache: 'force-cache' }に設定する。
    • Setting the segment config to fetchCache = 'only-cache', dynamicParams = false.
      セグメント設定をfetchCache = 'only-cache', dynamicParams = falseに設定。
    • dynamic = 'error' changes the default of dynamicParams from true to false. You can opt back into dynamically rendering pages for dynamic params not generated by generateStaticParams by manually setting dynamicParams = true.
      dynamic = 'error' は dynamicParams のデフォルトを true から false に変更します。手動で dynamicParams = true を設定することで、 generateStaticParams で生成されなかった動的なパラメータに対して、 動的にページをレンダリングするように戻すことができます。
  • 'force-static': Force static rendering and cache the data of a layout or page by forcing cookies(), headers() and useSearchParams() to return empty values.
    force-static': cookies()、headers()、useSearchParams()が空の値を返すように強制することで、静的レンダリングを行い、レイアウトやページのデータをキャッシュします。

File Conventions: Route Segment Config | Next.js

ちなみにfetch(URL, { cache: 'no-store' })か、export const dynamic = 'force-dynamic'(ページ内のフェッチはすべてcache: 'no-store'として扱われる)を記述するとSSRになります。

export const dynamicParams = trueが適用されるようにする

dynamicParamsの初期値はtrueなので、事前にビルドしなくてもISR対象できます。基本的に変更する必要はないと思います。

Control what happens when a dynamic segment is visited that was not generated with generateStaticParams.
generateStaticParams で生成されなかったダイナミックセグメントにアクセスしたときの動作を制御します。

  • true (default): Dynamic segments not included in generateStaticParams are generated on demand.
    true (デフォルト): generateStaticParams に含まれない動的セグメントは、オンデマンドで生成されます。
  • false: Dynamic segments not included in generateStaticParams will return a 404.
    false: generateStaticParams に含まれていない動的セグメントは 404 を返します。

File Conventions: Route Segment Config | Next.js

キャッシュができていない場合に確認すること

これまでの内容と重複しますが、公式ドキュメントで次のような確認項目が示されています。

Opting out of Data Caching

データキャッシュからの脱却

fetch requests are not cached if:
以下の場合、fetch リクエストはキャッシュされません:

  • The cache: 'no-store' is added to fetch requests.
    cache: 'no-store'がfetch リクエストに追加されている。
  • The revalidate: 0 option is added to individual fetch requests.
    個々の fetch リクエストに revalidate: 0 オプションが追加されている。
  • The fetch request is inside a Router Handler that uses the POST method.
    fetchリクエストがPOST メソッドを使うルータハンドラの中にある。
  • The fetch request comes after the usage of headers or cookies.
    fetchリクエストはheadersまたはcookiesの使用後に来る。
  • The const dynamic = 'force-dynamic' route segment option is used.
    const dynamic = 'force-dynamic' ルートセグメントオプションが使用される。
  • The fetchCache route segment option is configured to skip cache by default.
    fetchCacheルートセグメントオプションはデフォルトでキャッシュをスキップするように設定されている。
  • The fetch request uses Authorization or Cookie headers and there's an uncached request above it in the component tree.
    fetchリクエストがAuthorizationヘッダまたはCookie ヘッダを使用していて、コンポーネントツリーでその上にキャッシュされていないリクエストがある。

Data Fetching: Fetching, Caching, and Revalidating | Next.js

chot Inc. tech blog

Discussion