🔥

Next.js Cacheのアツさをシェアしたい(App Router)

2023/05/05に公開
4

sumirenです。

2023年5月5日、ついにNext.js App Routerがstableになりましたね!
おめでとうございます!!ありがとうございます!!!
今から本番で使うのが楽しみで待ちきれません。

13.4のリリースではstableの宣言とともに、目玉機能としてServer Actionsが来ています。Data Fetch(というか、もはやData Handling的なもの)の機能の一部として、とても興味深いです。

さて、Server Actions自体の解説は他の方に任せるとして、リリースノートには以下のような一文があります。

Server Actions in Next.js have been designed for deep integration with the rest of the data lifecycle, including the Next.js Cache, Incremental Static Regeneration (ISR), and the client router.

この記事では、ここにあるNext.js Cacheについて解説したいと思います。

Next.js Cache...?

Next.js Cacheという言葉自体初耳の方も多いかと思いますが、この概念はしれっとNext.js 13.2のリリースに含まれています。
ここで言われていることをざっくりまとめると、以下のようなことです。

App Routerにおいて、

  1. より柔軟なデータフェッチ戦略がとれるようになった
  2. 開発効率がよくなった(next dev
  3. Vercelへのデプロイ効率が上がった(一緒にVercel Cache APIもGAしている)

他にも、ドキュメントを見ると、「Next.js CacheはHTTPキャッシュで実現されているからCDNネイティブだ」みたいな話もあるのですが、総じてNext.js Cacheという機能の厳密なスコープは定義されていないように思います。

筆者が推したいのは、1.の「より柔軟なデータフェッチ戦略がとれるようになった」で、この記事では、筆者の見解を交えながらこれについて解説します。

3.のVercel Cache API等に関心があってこの記事を開いた方は、この記事から得るものはないかもしれません。
ファクトのみを述べるのではなく筆者の見解を含めることがこの記事のコンセプトのため、ファクトのみをフラットに知りたい方や、筆者よりも高いフロントエンド技術を持つ方にとっては、この記事はノイズが多いかもしれません。
また、先述のServer Actionsやrouter.refresh()といったミューテーション関連の話題にも触れません。フェッチのキャッシュと関連があるものの、分けて考えられると判断したためです。

検証に使ったコードはGitHubにあります。

Next.js 13.0時点のFetch

まずは、Next.js Cacheが来る前のApp RouterのFetchについて見ていきます。

App Router Data Fetchのおさらい

React Server Componentと密に統合されたData Fetchは、App Router公開時点でも目玉機能の1つでした。

Pages Dirでは、Data Fetchは以下のようにPageファイルにgetXXXPropsを定義し、データをバケツリレーしていました。

Before
// Pageのpropsに渡るため、バケツリレーが必要
export async function getStaticProps() {
  const res = await fetch('https://.../posts')
  const posts = await res.json()

  return {
    props: {
      posts,
    },
  }
}

App Routerでは、React Server Componentsを活用することで、コンポーネントから直接データをフェッチする書き方が推奨されるようになりました。
このfetch()関数の引数にrevalidate等を指定することで、旧来のSSR / SG / ISRといったキャッシュ戦略を実現できます。

After
// ServerComponentごとに取得できるため、ある程度バケツリレーを防げる
export async function ServerComponent() {
  const data = await (await fetch(`https://api.example.com/artist/1`)).json();

  return (
    <>
      <Albums data={data}></Albums>
    </>
  );
}

筆者は当時(2022年10月頃)、たしかにこの機能で開発生産性は高くなりそうだと感じていました。コンポーネントから直接async/awaitを取り扱うことができ記述量は減りますし、バケツリレーを回避することができました。

また、今まで以上にクライアントサイドデータフェッチではなくサーバーサイドでのフェッチを活用し、revalidaterouter.refreshといったAPIでクライアントサイドキャッシュを更新するという、今までと根本的に異なる開発スタイルを確立しそうだとも感じていました。
(Server Actionsのようなものは想像できていませんでしたし、revalidateがサーバー側で呼ぶものになるとも想像できていませんでしたが)

Next.js 13.1以前のFetchの課題

開発生産性や開発スタイルについては期待が膨らむ一方、腑に落ちない点もありました。それは、Next.js 13.0時点のFetchは、端的に言えばWebアプリケーションのパフォーマンスを向上してはおらず、できることはgetXXXProps時代とほとんど変わっていないと思えたことです。

例えば、App Routerで以下のようなコンポーネント構成を想像してみてください。

  • コンポーネント
    • コンポーネントAでfetch(".../api1")、SG
    • コンポーネントBでfetch(".../api2")、30秒のISR
    • コンポーネントCでfetch(".../api3")、SSR=リクエスト毎のフェッチ
  • Page
    • コンポーネントA、Bを使うPageX
    • コンポーネントB、Cを使うPageY

このとき、PageYに対するリクエストを行うと、Next.jsサーバーから外部へのAPIリクエストはいくつ飛ぶでしょうか。
正解は、Next.js 13.0時点では2つです。

これは、キャッシュ戦略がルート(セグメント)単位で決まるというNext.js 13.0時点の仕様に因るものです。

例えば上記例では、

  • PageX → SGとISR → 一番頻度が高いのは30秒のISR → api1とapi2を30秒のISRで呼び出し
  • PageY → ISRとSSR →一番頻度が高いのはSSR → api2とapi3をリクエスト毎に呼び出し

といった動きになっていました。

つまり、同じPage内では異なるデータに対して単一のキャッシュ戦略しか適用できていなかったということです。これは、結局の所getXXXPropsで実現できていたこととほとんど変わりがありません。

この振る舞い自体はドキュメントに特別言及があったわけではなく、実際にNext.js 13.0〜13.1を動作検証することで発見したものです。再現するコードをこの記事のはじめのほうに載せていますので、興味のある方は動かしてみてください。

Next.js Cacheで進化したFetch

Next.js 13.0時点でのFetchの課題を踏まえ、Next.js Cacheで何が変わったか、何がアツいのかを説明します。

Next.js 13.2のリリース内容

勘の鋭い方なら、リリースノートにあった以下の一文の意味合いがピンときたのではないでしょうか。

Progressive ISR at the component level

ご想像の通り、Next.js Cacheが来たNext.js 13.2以降、コンポーネントごとにキャッシュ戦略が適用されるようになりました。

先程同様の以下のような条件では、

  • コンポーネント
    • コンポーネントAでfetch(".../api1")、SG
    • コンポーネントBでfetch(".../api2")、30秒のISR
    • コンポーネントCでfetch(".../api3")、SSR=リクエスト毎のフェッチ
  • Page
    • コンポーネントA、Bを使うPageX
    • コンポーネントB、Cを使うPageY

以下のような動作となります。

  • PageX → SGとISR → api1はSGで一度だけ取得し、api2はISR
  • PageY → ISRとSSR → api2はISRで呼びつつ、api3はリクエスト毎に呼び出し

補足:腑に落ちないこと

正直、リリースノートが腑に落ちないポイントはあります。
まず、「Progressive ISR」という言葉が何を意味しているのか。ISRだけを取り上げている理由が分かりません。
また、Next.js Cacheが13.2〜13.3までベータとされていた理由もよく分かりません。App RouterがベータだったからNext.js Cacheもベータだったということでしょうか。

ですが、リリースノートの意図はともかく、SSRやSGなど含めて全てのキャッシュ戦略がat the component levelでコントロールできていることは確かです。ステータスについても、13.4でベータが外れています。ということで、あまり気にしないことにしています。

推しポイント

このフィーチャーはかなり汎用的だと思います。例えば異なるキャッシュ期間のISRを共存させられるなど、様々なユースケースがあるでしょう。

筆者が一番アツいと思っているのは、Streaming SSRとのシナジーです。

認証が必要なページに、SGやISRで取得するデータも含まれている場合を考えてみてください。
認証を必要とする部分は(クライアントサイドデータフェッチでなければ)リクエスト毎のサーバーサイドフェッチになるため、雑な例ですが以下のような構成になります。

  • コンポーネント
    • コンポーネントAでfetch(".../banner")、20秒のISR
    • コンポーネントBでfetch(".../purchases")、SSR=リクエスト毎のフェッチ
  • Page
    • 上記2つのコンポーネントを使う

このとき、Next.js CacheとStreaming SSRを組み合わせることで、今までにない体験が実現できます。

まず、コードは以下のようになります。上記例と直接対応しておらずすみませんが、
HomeでfetchしているのがISRのデータ、ServerComponentでfetchしているのがSSRのデータです。

Streaming SSR + ISR
import {Suspense} from "react";

export default async function Home() {
  const [staticData] = await Promise.all([
    fetch(`http://localhost:3001/api/nice`, {next: {revalidate: 20}}),
  ]);

  return <>
    <div>Next.js 13.1</div>
    <div>{JSON.stringify(await staticData.json())}</div>
    <Suspense fallback={<p>fetching...</p>}>
      { /* @ts-ignore */ }
      <ServerComponent />
    </Suspense>
  </>
}

const ServerComponent = async () => {
  const [dynamicData] = await Promise.all([
    fetch(`http://localhost:3001/api/hello`, { cache: 'no-store' }),
  ]);
  return <div>{JSON.stringify(await dynamicData.json())}</div>
}

このとき、Next.js 13.0時点では、ISRでとれている分までリクエスト毎SSRでフェッチしてしまっていました。バックエンドに無駄に負荷がかかるのもそうですが、何より、せっかくISRで事前取得したデータを爆速で表示できなかったことが一番惜しいポイントでした。

上記2本のAPIをあえて3秒くらいかかる遅いAPIにして上記を実行すると、次のような動きになります。

  • ISRもSSRにひきずられてリクエストする
  • ISR分はSuspenseになっていないため、Streaming SSRではない同期SSRとなる
  • 結果、レスポンスに3秒以上かかる画面になる

動画もあります。Zennに貼れなかったためTwitterのリンクとなります。
https://twitter.com/sumiren_t/status/1654319081984774144

もちろん、ISRの部分もSuspenseで囲えばデータ以外の部分は爆速表示できましたが、せっかくのISRの良さを活かせない仕様だったことに変わりはありません。

Next.js Cacheリリース後は、以下のような望ましい挙動となっています。

  • ISR分とプリレンダリングしたリソースは初回リクエストに対して即レスポンスする
  • SSR分は取得が終わったタイミングでStreamingで返す

動画は以下です。早すぎてちょっと分かりづらいですが、しっかりISR分は初回レスポンスに含まれており、ローカルでは20ms程度で返ってきています。
https://twitter.com/sumiren_t/status/1654322376644112384

Streaming SSRと様々なデータフェッチ戦略を組み合わせられるようになったことで、getXXXProps時代を圧倒的に超えるUXを実現可能になった点が、筆者のNext.js Cacheの推しポイントです。

おまけ:注意点

2点あります!

1. cacheはあくまでコンポーネント単位

このcacheはコンポーネント単位である点には注意してください。
例えば、コンポーネントAにSSR(リクエスト毎)とISRの2つのfetchがある場合、両方ともSSR(リクエスト毎)に引っ張られます。

2023/05/07 追記:

これは筆者の誤りでした。
同一Server Componentの中で様々なfetch戦略を使っても、しっかりfetch単位で取得頻度をコントロールできます。最高ですね。

コメントでのご指摘ありがとうございます!

2. fetchのデフォルトの挙動

公式ドキュメントによると、fetchに引数を指定しなければ、"force-cache"と同じ意味になるとあります。
しかし、仕様かバグか分からないのですが、デフォルト値のSGとISRが同一コンポーネントに共存すると、SGのフェッチがISRのフェッチに引きずられてしまう現象を確認しました。
「後からコンポーネント機能にフェッチを足したら、既存のフェッチ頻度が知らない間に変わっていた」といった事象に遭うのを避けるため、明示的に"force-cache"を指定するのが良さそうだと筆者は考えています。

蛇足:Next.js 13.4時点でFetchに足りない最後のピース

ここまでNext.jsの進化を称賛してきましたが、Data Fetchに関して、筆者には欠けている最後のピースがあるように思えています。
それは、キャッシュ戦略がfetch()関数と密結合しており、かつfetch()関数のシグネチャがHTTPベースのリクエストにしか対応していないことです。

例えば、Prisma経由でDBから直接データを取得し、ISRしたいとしたらどうなるでしょうか。gRPCのバックエンドに対してSGでデータを取得したいとしたらどうなるでしょうか。
Next.js 13.4時点では、どうしようもないというのが筆者の見解です。
cache()関数という関数もあるのですが、これはrequest dedupingのためのReactのAPIそのままであり、フェッチ戦略が実現できるものではありません。

これは、getXXXProps時代にはできていたことです。普通にgetXXXPropsを定義して、その中で好きな非同期処理をするだけです。
つまり、getXXXPropsからデグレードしてしまっている部分が残っているということです。

個人的には、この問題はクリティカルです。実際に本業・副業の現場を見ていても、バックエンドがシンプルなHTTPベース(いわゆるRESTっぽい)であることのほうが少なく、実用性に欠けます。
それに、Next.jsはフルスタックFWを目指しており、Server Actionsが想定するユースケースにはDBへの直接の書き込みが含まれます。であれば、Data FetchでもDB直読みを主要ユースケースとして想定していないと辻褄が合わないのではないでしょうか。

それっぽいコードはすでにコミットされているため、13.5あたりで解消される可能性はありそうだと思っています。

個人的にはfetch()関数はほとんど使わずに、この(unstable_)cache関数を多用することになるのではと思っています。

蛇足中の蛇足

上記コミットは5月に入ってからのようなのですが、大体同じようなインターフェースで「どう?」というDiscussionを4月の前半に切っていたので、我ながら少し先見性があったのでは、と思っています。(自慢)
https://github.com/vercel/next.js/discussions/48420
自慢はともかく、役に立てたかという観点でいうと、無視されているので役には立っていません。そもそもDiscussion建てること自体がアプローチとして役に立っていないのか、英語が雑すぎて理解できる内容になってないのかとか、どうやったら役に立てるのだろうと悩んでいます。

まとめ

  • App RouterのData Fetchで惜しかったところがNext.js Cacheで解消されて最高になった
  • 色々ユースケースはあると思うが、筆者はStreaming SSR + SG / ISRが激アツだと思っている
  • 癖はあるので注意が必要。動作確認しながら実装するとよさそう
  • あと1つ機能が足りないと思っている。早く来てほしい!

筆者はApp Router発表直後にこの機能を検証して「惜しい!!!」と思っていました。
Next.js Cacheのリリース後も、リリースノートの文章が曖昧で「これはアレが来たということか...?いや、多分違うよな...」とずっと放っていたのですが、今回検証してみたらまさかの超強化で感動もひとしおでした。

Next.js盛り上げていきましょう!!!

Discussion

koichikkoichik
  1. cacheはあくまでコンポーネント単位

Next.jsはフェッチ単位のキャッシュも持っています
フェッチ単位キャッシュの保存先は.next/cache/fetch-cache/で、ファイル名がハッシュになっているので分かりにくいですが、中身はレスポンスのヘッダやボディ (Base64でエンコードされてます) に加えてrevalidateなどのメタ情報を持ったJSONです

例えば、コンポーネントAにSSR(リクエスト毎)とISRの2つのfetchがある場合、両方ともSSR(リクエスト毎)に引っ張られます。

引っ張られてNext.jsによるラッパー版のfetch関数が実行されるのは確かですが、そのラッパー版fetch関数は前述のキャッシュを参照するので本来のfetch関数が実行されるとは限りません
キャッシュが有効であれば本来のfetch関数は実行されず、HTTPリクエストも送信されません
自分が試した限りではNext.js公式ドキュメントの3つ (static/dynamic/invalidated) のfetchを呼び出すstaticなコンポーネントの例は期待通りの動作をしているように見えます (13.4.1で確認、13.3でも同じだったはず)

sumirenさんがTwitterに貼った再現動画は以下のssr-and-isrを動かしたものでしょうか?
https://github.com/sumiren/nextjs-cache-exploration/blob/main/2-nextjs-cache-invalid/app/ssr-and-isr/page.tsx

これをNext.js 13.4.1で試したところ、/api/helloはやはりフェッチ単位キャッシュが効いていました
APIハンドラ側で3秒のタイマが入っていて2つのfetchが共に実行されると計6秒かかるのでrevalidateが10秒だとすぐにキャッシュが切れてしまってちょっと確認がしにくいですね
なのでapi/helloのrevalidateは30秒にして試しました
30秒の間であればリロードしても新しいタブでリクエストしても/api/helloへのHTTPリクエストは発生していません (APIサーバ側にapi1のログが出ない)
もしフェッチ単位キャシュが効かない例が他のページなら再現方法を教えてください

  1. fetchのデフォルトの挙動

公式ドキュメントによると、fetchに引数を指定しなければ、"force-cache"と同じ意味になるとあります。

公式ドキュメントの「Good to know」に書いてあるのですが (分かりにくいですね)

If you don't provide a cache option, Next.js will default to force-cache, unless a dynamic function such as cookies() is used, in which case it will default to no-store.

つまりコンポーネントがデフォルトのstaticであれば (その場合に限り)、その中から呼び出されたfetchのoptions.cacheforce-cacheがデフォルトになる、というわけですね

sumirensumiren

koichikさん

ありがとうございます!!

APIハンドラ側で3秒のタイマが入っていて2つのfetchが共に実行されると計6秒かかるのでrevalidateが10秒だとすぐにキャッシュが切れてしまってちょっと確認がしにくいですね
なのでapi/helloのrevalidateは30秒にして試しました

私も試してみました!
やはり13.4では再現したのですが、13.4.1ではおっしゃるとおりの挙動になりました...!!
勘違いかもですが、サイレント修正されたのかもしれません。
もしよろしければ、13.4で試してみていただけないでしょうか...!!!
一応13.2でも試していて、同じ挙動になった認識です。

切り分けでき次第、「フェッチ単位のキャッシュ」という表現とあわせて、記事修正します!

つまりコンポーネントがデフォルトのstaticであれば (その場合に限り)、その中から呼び出されたfetchのoptions.cacheはforce-cacheがデフォルトになる、というわけですね

なるほどです!仕様かもしれないと思っていたのでバグとは断定していなかったのですが、やはり仕様でしたね!追記しておきます!

sumirensumiren

追記:
今もう一度13.4.0で試してみたら、たしかにおっしゃるとおり最初のfetch文は発行されませんでしたね...
私の盛大な勘違いかもしれません...!!!
もうちょっと試してみます!!!

sumirensumiren

koichikさん

結論、おっしゃるとおりNext.js Cache後はfetch単位になっていそうです!!!すごい!!!

色んなパターンで検証したつもりでしたが、これで勘違いしたようです。

つまりコンポーネントがデフォルトのstaticであれば (その場合に限り)、その中から呼び出されたfetchのoptions.cacheはforce-cacheがデフォルトになる、というわけですね

記事修正します!