Next.jsのApp Router ISRをLambda Web Functions + CloudFront OACで動かしてみる

Next.jsのApp Router ISRをLambda Web Functions + CloudFront OACで動かしてみる

Lambda Web Functions 上で Next.js の ISR がどう動作するか検証してみました。CloudFront のキャッシュとの相互作用に焦点を当て、ページの更新タイミングと内容の古さがどの程度になるのかを動画で追跡しています。
2026.10.14

はじめに

Lambda Web Functions で Next.js App Router の ISR が使えるかを試しました。Amplify Hosting は使わず、output: 'standalone' でビルドした Next.js をそのまま Web Function で動かし、前段に CloudFront を OAC 付きで置いています。

確認したいのは次の 2 点です。

  • 時間が経つとページが再生成されるか
  • CloudFront のキャッシュと重なったとき、内容がどれだけ古くなるか

CloudFront 経由の endpoint とは別に、オリジン単体の挙動を比べるための直接アクセス用 endpoint も用意しています。

次の動画は、ブラウザで /isr/1?v=10 に繰り返しアクセスしたときの録画です。

https://youtu.be/Fki83tzAM_E

構成

構成図

CloudFront Function がキャッシュヒット時も Server-Timing を復元する、CloudFront OAC と Next.js ISR の構成図

経路 endpoint 認証 用途
Client → CloudFront → Web Function prod IamAuth CloudFront のキャッシュと OAC を含む挙動を確認
Client → Web Function direct ApplicationManaged CloudFront を経由しないオリジン単体の ISR を確認

Next.js は 16.4.0、React は 19.3.0、Web Functions のランタイムは nodejs24.x、リージョンは ap-northeast-1 です。

ソース解説

ソースコードは次のリポジトリにあります。CDK のスタックは lib/next-web-function-stack.ts、Next.js のアプリは app-next/ です。

https://github.com/shuntaka9576/cf-lambda-web-functions

ISR の設定

/isr/1 はビルド時に生成し、revalidate = 10 で時間ベースの再生成をします。

https://github.com/shuntaka9576/cf-lambda-web-functions/blob/83fee51e191c077a0b01eb58e82ede3e52915bcc/app-next/app/isr/[id]/page.tsx#L4-L12

expireTime は 20 秒です。仕様は Next.js の ISR ガイドと expireTime の説明を参照してください。

https://github.com/shuntaka9576/cf-lambda-web-functions/blob/83fee51e191c077a0b01eb58e82ede3e52915bcc/app-next/next.config.ts#L4-L15

Web Function で Next.js を起動する

Lambda が渡す AWS_LAMBDA_HTTP_ENDPOINT を、standalone の server.js が読む HOSTNAME と PORT に変換してから起動します。実行環境ごとの ID(POC_ENV_ID)もここで作ります。

https://github.com/shuntaka9576/cf-lambda-web-functions/blob/83fee51e191c077a0b01eb58e82ede3e52915bcc/app-next/lambda/index.js#L4-L14

コードを ZIP で配布する

Web Functions のコードは、バージョニングを有効にした S3 バケットの ZIP から読み込みます。standalone の出力に .next/static、public、index.js を加えて、自分で ZIP にしています。

https://github.com/shuntaka9576/cf-lambda-web-functions/blob/83fee51e191c077a0b01eb58e82ede3e52915bcc/lib/next-web-function-stack.ts#L50-L97

CDK に ZIP を任せると、ファイルの日時が 1980-01-01 に固定されます。Next.js は事前生成ページの描画時刻をファイルの mtime から取るので、事前生成ページが常に期限切れ扱いになってしまいます。

Web Function と endpoint

https://github.com/shuntaka9576/cf-lambda-web-functions/blob/83fee51e191c077a0b01eb58e82ede3e52915bcc/lib/next-web-function-stack.ts#L106-L152

maxConcurrencyPerEnvironment を 2 に絞り、複数の実行環境が立つようにしています。NEXT_PRIVATE_DEBUG_CACHE=1 で incremental cache のログも出します。direct endpoint は認証がないので、maxEnvironments と rateLimit で流量を絞っています。

CloudFront と OAC

https://github.com/shuntaka9576/cf-lambda-web-functions/blob/83fee51e191c077a0b01eb58e82ede3e52915bcc/lib/next-web-function-stack.ts#L154-L192

/isr* のキャッシュポリシーは、TTL を Next.js が返す Cache-Control に任せます。HTML と RSC ペイロードが混ざらないよう、RSC 系のヘッダーをキャッシュキーに入れています。キャッシュするのは /isr* と /_next/static/* だけで、それ以外は CACHING_DISABLED です。

Web Functions は AddPermission に対応していないので、リソースポリシーを丸ごと設定し、この Distribution からの呼び出しだけを許可します。

https://github.com/shuntaka9576/cf-lambda-web-functions/blob/83fee51e191c077a0b01eb58e82ede3e52915bcc/lib/next-web-function-stack.ts#L234-L249

CloudFront Function で Server-Timing を組み立て直す

ページの JavaScript からは HTML 自体のレスポンスヘッダーを読めず、読めるのは Server-Timing だけです。そこで proxy.ts で、オリジンが受けた時刻と実行環境の ID を x-poc-* ヘッダーと Server-Timing の両方に載せます。

https://github.com/shuntaka9576/cf-lambda-web-functions/blob/83fee51e191c077a0b01eb58e82ede3e52915bcc/app-next/proxy.ts#L3-L16

ところが、CloudFront はキャッシュから返すときに Server-Timing を落とします。AWS の説明

そこで /isr* の viewer-response で、キャッシュに残っている x-poc-* から Server-Timing を組み立て直します。

https://github.com/shuntaka9576/cf-lambda-web-functions/blob/83fee51e191c077a0b01eb58e82ede3e52915bcc/lib/next-web-function-stack.ts#L194-L210

組み立て直すのはオリジンが受けた時点の値なので、「ブラウザが受け取った時刻 − オリジンが受けた時刻」がおおよそ CloudFront にキャッシュされていた時間になります。通信時間や時計のずれも含むので、目安として扱います。

検証

方法

CloudFront 経由の /isr/1?v=10 をブラウザで繰り返し開き、画面を録画しました。ページには次の 3 つの時刻を表示しています。描画とオリジン受付には、実行環境の ID も並べています。

  • 描画:Next.js がページを描画した時刻(renderedAt)
  • オリジンが受けた:オリジンがリクエストを受けた時刻(x-poc-origin-at)
  • ブラウザが受け取った:ブラウザがレスポンスを受け取った時刻

「ブラウザが受け取った時刻 − 描画時刻」を内容の古さとして見ます。録画(65.2 秒)は ffmpeg で静止画に切り出し、全体を 0.5 秒間隔、冒頭・切り替わり前後・終了部分を 0.1 秒間隔で確認しました。時刻は JST です。

キャッシュに関わる設定値は次のとおりです。

設定 値 意味
revalidate 10 秒 描画から 10 秒を過ぎると古いとみなし、古い内容を返しながら裏で再生成する
expireTime 20 秒 描画から 20 秒を過ぎた内容は返さず、その場で描画し直す
オリジンが返す Cache-Control s-maxage=10, stale-while-revalidate=10 上の 2 つから Next.js が付ける。CloudFront は最初の 10 秒は新しいものとして返し、その後の 10 秒は古い内容を返しながら裏で取り直せる
CloudFront の TTL 最小 0 秒 / デフォルト 0 秒 / 最大 365 日 オリジンの Cache-Control に従う

結果

https://youtu.be/Fki83tzAM_E

65.2 秒の録画全体で、内容 A から E までの更新とブラウザで受け取った内容の古さを示すタイムライン

以下の見出しの秒数は、タイムライン下段の横軸(A の描画からの秒数)に対応します。録画の時刻は、冒頭の入力・読み込みの分だけ約 1.6 秒後ろにずれています。

0 秒:初回の内容 A

内容 A の描画時刻は 08:59:26.356、初回の受信時刻は 08:59:26.382 で、古さは 0.026 秒でした。

タイムラインの 0〜10 秒を強調した図。A を初めて受信した区間

初回の内容 A。描画時刻 08:59:26.356、受信時刻 08:59:26.382
録画 00:02.0 の切り出し。動画で確認する(00:01〜)

30 秒:A の古さが 29.8 秒になった場面

オリジン受付時刻が 08:59:46.048 の応答にも A が残っており、この時点で描画から 19.692 秒経っています(タイムラインのオレンジの矢印「19.7 秒」)。ブラウザが 08:59:56.113 に受信した内容も A で、古さは 29.757 秒(表示は 29.8 秒) でした。

CloudFront のキャッシュ有効期限の仕様から、頻繁にアクセスすれば古さは最大で約 20 秒に収まると予想していましたが、それを超えています。

原因は、CloudFront が 08:59:36.246 に取り直したとき、A が描画から 9.9 秒で revalidate の 10 秒をわずかに下回っていたことです(タイムラインの矢印「9.9 秒」)。Next.js は A をまだ新しいとみなし、再生成しませんでした。その分 1 周期遅れ、描画から約 20 秒経った A を CloudFront がさらに s-maxage の 10 秒保持したため、29.8 秒になったと考えています。

タイムラインの 10〜30 秒を強調した図。CloudFront が A を 2 回再取得し、古さが 29.8 秒まで増える区間

内容 A の古さが 29.8 秒になった場面。描画 08:59:26.356、オリジン受付 08:59:46.048、受信 08:59:56.113
録画 00:31.4 の切り出し。動画で確認する(00:30〜)

30 秒:B への切り替わり

次に確認した 08:59:56.385 の受信では、描画時刻が 08:59:46.055 の内容 B に切り替わり、古さは 10.330 秒(表示は 10.3 秒) でした。この前後の実行環境 ID は、いずれも 0d415d84-d928-4ffb-96f9-e472919e4c03 です。B に切り替わっても、B 自体が約 10 秒前に描画された内容なので、古さは 0 秒には戻りません。

タイムラインの 30〜40 秒を強調した図。B に切り替わり、古さが 10.3 秒に戻る区間

内容 B へ切り替わり、古さが 10.3 秒になった場面。描画 08:59:46.055、受信 08:59:56.385
録画 00:31.6 の切り出し。動画で確認する(00:31〜)

40〜60 秒:C・D・E への更新

その後も同じ実行環境 ID のまま C、D、E へ切り替わりました。B・C・D は古さが約 20 秒まで増えてから、次の内容の約 10 秒へ戻っています。

タイムラインの 40〜60 秒を強調した図。C・D・E へ約 10 秒ごとに切り替わる区間

内容 描画時刻 この内容の最初に確認した受信時刻 その受信時の古さ
A 08:59:26.356 08:59:26.382 0.026 秒
B 08:59:46.055 08:59:56.385 10.330 秒
C 08:59:56.167 09:00:06.462 10.295 秒
D 09:00:06.199 09:00:16.606 10.407 秒
E 09:00:16.308 09:00:26.547 10.239 秒

C への更新(10.3 秒)

内容 C。描画 08:59:56.167、受信 09:00:06.462、古さ 10.3 秒
録画 00:41.7 の切り出し。動画で確認する(00:40〜)

D への更新(10.4 秒)

内容 D。描画 09:00:06.199、受信 09:00:16.606、古さ 10.4 秒
録画 00:51.8 の切り出し。動画で確認する(00:50〜)

E への更新(10.2 秒)

内容 E。描画 09:00:16.308、受信 09:00:26.547、古さ 10.2 秒
録画 01:01.9 の切り出し。動画で確認する(01:00〜)

63 秒:録画終了時

最後に表示された受信時刻は 09:00:29.375 で、内容 E の古さは 13.067 秒(表示は 13.1 秒) でした。E を取得したオリジン受付時刻は 09:00:26.252 で、描画から 9.944 秒です(タイムラインの「E は 9.9 秒で取得」)。この録画には E より後の描画結果はありません。

タイムラインの終端を強調した図。E を 9.9 秒で取得し、録画終了時の古さが 13.1 秒になる区間

録画終了時の内容 E。受信 09:00:29.375、古さ 13.1 秒
録画 01:05.1 の切り出し。動画で確認する(01:04〜)

図のオリジンと CloudFront の流れは、画面上の描画時刻・オリジン受付時刻から整理したものです。録画には生のキャッシュ判定ヘッダーや再生成完了時刻はなく、オリジン受付から受信までの差には通信・処理時間も含まれます。29.8 秒は今回確認した一例であり、一般的な最大値ではありません。

さいごに

Amplify Hosting を使わなくても、Lambda Web Functions 上で Next.js の ISR は設定どおりに動きました。ページは約 10 秒ごとに再生成され、CloudFront と重ねたときの古さも、ほとんどの時間は約 10〜20 秒に収まっています。

一方で、CloudFront を前段に置くときはパラメータに注意が必要です。今回の結果を見る限り、CloudFront は Next.js が描画した時刻ではなく、自分が取得した時刻から s-maxage を数えています。そのため、利用者に届く内容の古さは revalidate だけでは決まりません。通常は revalidate と s-maxage を足した程度になり、タイミングによっては expireTime と s-maxage を足した程度(今回の設定では約 30 秒)まで延びます。

revalidate や expireTime は、CloudFront に保持される時間も含めて、許容できる古さから逆算して決めるのがよさそうです。

この記事をシェアする

関連記事