🎉

ISR と オンデマンドISR を Next.js の挙動から完全に理解する

2023/05/29に公開

Next.jsを使っている時に ISR や On-demand ISRをを当たり前に使っているのですが、Next.js がどういうふうに動いているのか小さい検証環境を用意して検証しました。

ISR, オンデマンドISRとは

はじめに、ISRと On-demand ISR (On-demand revalidate) について軽くおさらいしておきます。

ISR

ISRSSG したページを revalidate で指定した時間を超えてからアクセスがあった時に、更新がないか再検証して、ページを更新する機能です。

オンデマンドISR

オンデマンドISR はこの ISR をさらに強化したような機能で、ISR だと revalidate で指定した時間を待たないとページが更新されなかったのに対して、任意のタイミングでページを更新できるようにしたものです。

使い方などはこの記事では触れないので、microCMSさんのブログなどを見ていただけると良いです。
https://blog.microcms.io/on-demand-isr/

早速検証に入っていきます。

検証方法

投稿の一覧リストページと投稿の詳細ページの二つのページを用意して、next build, next start してローカルで挙動を確認しました。

※Vercelにデプロイすると、Vercel側がCDNなどの共有キャッシュ向けのヘッダを消してしまって挙動がわかりづらいのでローカルで検証します。

ページを用意する

  • /posts.tsx
  • /posts/[id].tsx

と二つのパスを用意します。

/posts.tsx では getStaticProps で revalidate 10 を指定します。

export const getStaticProps = async () => {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/`);
  const json = await res.json();

  return {
    props: {
      array: json,
    },
    revalidate: 10,
  };
};
... 省略

/posts/[id].tsx では、getStaticPaths を blocking とした上で、revalidate を 10 秒としました。

export const getStaticPaths = () => {
  return {
    paths: [],
    fallback: "blocking",
  };
};

export const getStaticProps = async ({
  params,
}: {
  params: { id: string };
}) => {
  const id = params.id;
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
  const json = await res.json();

  return {
    props: {
      value: json.title,
    },
    revalidate: 10,
  };
};
... 省略

ビルドする

これを next build して .next ディレクトリを見てみます。

/posts は静的パスなので、./next/server/pages/posts.(html | json)と事前にビルドされていました。
/posts/[id]は動的パスで、getStaticPaths で paths を空にしていたので、HTML と JSON は事前にビルドされていないことがわかります。

.next
├── prerender-manifest.json
├── server
│   ├── pages
│   │   ├── posts
│   │   │   ├── [id].js
│   │   │   └── [id].js.nft.json
│   │   ├── posts.html
│   │   ├── posts.js
│   │   ├── posts.js.nft.json
│   │   └── posts.json

この時の .next/prerender-manifest.jsonを見て見ます。

{
  "version": 4,
  "routes": {
    "/posts": {
      "initialRevalidateSeconds": 10,
      "srcRoute": null,
      "dataRoute": "/_next/data/uPyZrodtZpnn-e5AvV7r_/posts.json"
    }
  },

静的パスの/postsは、initialRevalidateSeconds(初回の再検証までの時間)が10秒であることがわかります。initialとなっているのは、getStaticProps関数で次にrevalidateさせたい時間を動的に変更することができるからです。
例えば、朝昼晩の3回更新して、深夜は更新したくないようなケースなど、ちょっと複雑なISRを実現できます。

  "dynamicRoutes": {
    "/posts/[id]": {
      "routeRegex": "^/posts/([^/]+?)(?:/)?$",
      "dataRoute": "/_next/data/uPyZrodtZpnn-e5AvV7r_/posts/[id].json",
      "fallback": null,
      "dataRouteRegex": "^/_next/data/uPyZrodtZpnn\\-e5AvV7r_/posts/([^/]+?)\\.json$"
    }
  },
  "notFoundRoutes": [],
  "preview": {
    "previewModeId": "2182ff802151d1c01af9cac9edd6b87e",
    "previewModeSigningKey": "7896a29d988799e767a457afeaaf886b09ff116940bfb1d87480a4783739690c",
    "previewModeEncryptionKey": "bbc50b5dc0f1899b0c30f931bc2716cc924b7bf2616b31cb45becf1e43374f07"
  }
}

動的パスの/posts/[id] はfallbackがnull(blocking)の動的パスであることがわかります。
初回アクセス時に HTMLとJSON をインメモリまたは.nextディレクトリに生成、レスポンスを返します。

ここで、dataRouteという怪しいパスが存在するのが見えるのですが、Next.js/next/data/uPyZrodtZpnn-e5AvV7r/posts.json のようなパスを自動で生成していて、アプリケーションでgetStaticPropsが返したデータが必要になった場合にここにアクセスをしています。next/linkprefetchを見るとこのjsonに対してのリクエストが送られていて、アクセスがあったタイミングでrevalidateされるので、ユーザーが開く時にはページが完成していて快適に動作します。

実際にアクセスした時のヘッダを見る

/posts を開いた時のレスポンスを見ると、Cache-Control ヘッダs-maxage=10, stale-while-revalidate がついていて、CDNキャッシュを活用して10秒ごとにrevalidateが走るようになっていることがわかります。

動的ルートの場合も同様に、キャッシュコントロールヘッダがついてrevalidate: 10 の挙動になることがわかります。

オンデマンドISRはなんで動くの?

ここで少し気になったのが、今 next start した状態で付与されているヘッダなら、CDNのキャッシュをrevalidateで指定した時間ずっと参照してしまって、オンデマンドISRが動かないはずということです。
先ほどのコードを少し変更して検証します。

/postsに対してオンデマンドRevalidateを実行するAPIルートを用意します。

/api/revalidate
import type { NextApiRequest, NextApiResponse } from "next";

export default async function handler(
  _req: NextApiRequest,
  res: NextApiResponse
) {
  await res.revalidate("/posts");
  res.status(200).json({ status: "ok" });
}

/postsのrevalidateが10秒だとわかりづらいので60秒に変更します。

export const getStaticProps = async () => {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/`);
  const json = await res.json();
  const time = Date.now();

  return {
    props: {
      array: json,
      time: time,
    },
    revalidate: 60,
  };
};

これをローカルで実行した際のキャッシュコントロールヘッダを見てみます。
CDNで60秒キャッシュして、その後のアクセスでrevalidateするような指定がありました。

Cache-Control: s-maxage=60, stale-while-revalidate

ローカルでヘッダは確認できたので、次にVercelにデプロイして確認してみました。
Vercel にデプロイした時の、ブラウザに対してのレスポンスヘッダは以下です。

Cache-Control: public, max-age=0, must-revalidate

ソースコードを見てみる

これだけだと何が起こっているのかわからないので、Next.jsのソースコードを見てみます。
revalidateを実行した時に実際に実行されるコードは自分が見た限りではここで、実際のページパスに対して、revalidateHeadersをつけたHEADリクエストを送っていました。

https://github.com/vercel/next.js/blob/canary/packages/next/src/server/api-utils/node.ts#L445-L461

revalidateHeaders と 関連する定数の値 は以下の通りでした。

https://github.com/vercel/next.js/blob/canary/packages/next/src/server/api-utils/node.ts#L423-L442

https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/constants.ts#L5-L6

これらのコードから、最終的に以下のようなヘッダがついたHEADリクエストになることがわかりました。

'x-prerender-revalidate': '{ビルド時にできるID}'
'x-prerender-revalidate-if-generated': 1
'cookie': 'x-vercel-protection-bypass'
... ほかなんだろう

おそらく、VercelのCDNエッジは、このリクエストが来た時点でキャッシュを破棄して、Next.jsはページを再生成、キャッシュの再生成を行なってそうでした。

この理由なら、オンデマンドISR動きそうなことがわかりました👏

他プラットフォームでは動かない

AWS や GCP などの他のプラットフォームにデプロイした場合に、オンデマンドISRが動かないのはこれが理由です。

もしAWSなどで機能させたい場合は、Lambda@Edge でヘッダを見てCDNキャッシュパージするコードを仕込むか、revalidateを実行するAPIルートで、CloudFrontのキャッシュをパージするコードを仕込むなど少し荒技を使わないと実現できなさそうです。

最後に

Next.jsクローンしてコード追ってみてたら、Macがあったかくなりました。
良いNextライフを🎉

検証リポジトリ

リポジトリ名タイポしてるのマジで恥ずかしい…
https://github.com/2ndPINEW/rivalidate-playground

Discussion