iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🕐

Verifying the Behavior of "use cache"

に公開3

Introduction

While considering migrating a Next.js v15 application to Next.js v16, I needed to verify the behavior of "use cache" on Vercel, so I am writing this article as a side effect of that process.

Background

I was using unstable_cache because I didn't want to fetch data during build time, but I wanted it to be cached for a certain period after the first data fetch upon access. However, after upgrading to Next.js v16, the behavior changed, so I decided to switch to using "use cache" properly.

The official documentation alone is insufficient to fully understand the behavior of "use cache", and those aspects are being discussed in issues like the ones below. In particular, this comment in the first issue was very helpful for my understanding. In this article, I am verifying the behavior of "use cache" on Vercel while cross-referencing these comments. If there are any contradictions between this article and the discussions in the issues, please consider the issues to be correct.

https://github.com/vercel/next.js/issues/85240
https://github.com/vercel/next.js/issues/86686
https://github.com/vercel/next.js/issues/86760

Also, I have created a verification app, but if someone else purges the cache, you won't be able to confirm the exact behavior. If you want to check it properly, please deploy it to your own environment.

"use cache" Overview

First, I'd like to look back at what "use cache" was. This is already a well-covered topic, so please skip it or skim through if you already know.

To use the "use cache" feature, you need to switch the Next.js mode. Switching modes is simple; just add cacheComponents: true to your Next.js config.

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // Add this
  cacheComponents: true,
};

export default nextConfig;

Setting cacheComponents: true changes the existing Static/Dynamic specification method for page generation. By default, each page becomes Partial Prerender, and when "use cache" is specified at the top of the page, that page becomes Static. If a Dynamic Route or Dynamic API is used, it results in Partial Prerender. This is natural since the page must be generated using parameters that are only known at the time of access.

You can output cache logs by setting the environment variable NEXT_PRIVATE_DEBUG_CACHE=1.

Static

Pages or components with "use cache" at the top are rendered as Static at build time. However, since they are regenerated when the cache is purged, I think understanding them as being "cached" is closer to the reality than being truly static pages as the directive's name implies.
static/cached

Example build log
Route (app)                    Revalidate  Expire
 /
 /_not-found
 /dynamic/component/[slug]
 /dynamic/component/[slug]
 /dynamic/data/[slug]
 /dynamic/data/[slug]
 /static/cached                    15m      1y
 /static/uncached                  15m      1y


  (Static)             prerendered as static content
  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

If there is a part you want to render dynamically and you try to wrap it in Suspense forcibly, it won't have any effect. It will be rendered at build time regardless.

Partial Prerender

First, what is Partial Prerender? The page itself is dynamically generated, and you can specify Static or Partial Prerender for each individual element or piece of data within it.
static/uncached

Example

For example, the following page.tsx does not have "use cache" specified, so it becomes a Partial Prerender page, and the page itself is rendered dynamically for each request. Cached and CachedRemote have "use cache" specified within their respective components, so these parts are cached, while the Uncached component is not cached and is generated dynamically for each request. Dynamic components must be wrapped in Suspense to specify a fallback during rendering.

page.tsx
export default async function Page() {
  return (
    <PageLayout>
      <Cached />

      <CachedRemote />

      <Suspense fallback={<LoadingCard />}>
        <Uncached />
      </Suspense>
    </PageLayout>
  );
}
component.tsx
interface Props {
  params?: Promise<{ slug: string }>
}

export async function Cached({ params }: Props) {
  "use cache"

  const { slug } = params ? await params : {}
  cacheTag(`cached-component-${slug}`, "component", "all")

  await wait(5)
  const date = new Date()

  return (
    ...
  )
}

export async function CachedRemote({ params }: Props) {
  "use cache: remote"

  const { slug } = params ? await params : {}
  cacheTag(`cached-remote-component-${slug}`, "component", "all")

  await wait(5)
  const date = new Date()

  return (
    ...
  )
}

export async function Uncached({ params }: Props) {
  const { slug } = params ? await params : {}

  await wait(5)
  const date = new Date()

  return (
    ...
  )
}

Summary

To summarize briefly, when you switch modes with cacheComponents: true, it works as follows:

  • All components will either be cached or dynamically generated. (The default is dynamic)
    • Cached components are pre-rendered at build time.
    • Dynamically generated components must be wrapped in Suspense.
  • Within a Partial Prerender page, you can choose whether to cache or dynamically generate each individual element.

Dynamic Routing

This is the main topic of this article. If you want to cache components within Dynamic Routing based on the understanding summarized above, the version where parameters are not passed will be cached as follows:

/dynamic/component/[slug]/page.tsx
export default async function Page({
  params,
}: PageProps<"/dynamic/component/[slug]">) {
  return (
    <PageLayout>
      {/* When params are not passed */}
      <Cached />

      {/* When params are passed */}
      <Suspense fallback={<LoadingCard />}>
        <Cached params={params} />
      </Suspense>
    </PageLayout>
  )
}

interface Props {
  params?: Promise<{ slug: string }>
}

async function Cached({ params }: Props) {
  "use cache"

  const { slug } = params ? await params : {}

  return (
    ...
  )
}

As expected, the component where params are not passed is cached and rendered immediately, while the component where params are passed triggers rendering every time.
dynamic/component/cache/1

dynamic page in cache

So, what should we do if we want to cache rendering results or data that use Dynamic APIs? There might be use cases where parameters are unknown at build time or you don't want to render them during the build, but you want to cache the results once an access occurs. In this case, you will use "use cache: remote".

"use cache: remote"

First, let's look at the results. Components with "use cache" are rendered every time, just like before, but components with "use cache: remote" are cached as expected.
dynamic/component/1

dynamic page in cache remote

Reading the documentation for "use cache: remote", it says the following:

The 'use cache: remote' directive lets you declaratively specify that a cached output should be stored in a remote cache instead of in-memory. While this gives you more durable caching for specific operations, it comes with tradeoffs: infrastructure cost and network latency during cache lookups.

It seems using "use cache: remote" means it will be cached remotely rather than in-memory. If you read further, it's also explained here when it should be used.

Remote caching provides the most value when content is deferred to request time (outside the static shell). This typically happens when a component accesses request values like cookies(), headers(), or searchParams, placing it inside a Suspense boundary.

It is most effective when you want to cache content at request time—in other words, exactly what we want to achieve.

Where to `await`

When you want to cache rendering results or data fetched using Dynamic Routing params or other Dynamic APIs, the placement of await can be tricky. Specifically, there are two patterns:

  • Wrapping everything in Suspense and passing resolved values to the cached component.
  • Passing the Promise as-is and resolving it inside the component.

In the documentation example, the Promise is resolved at the top of the component within Suspense before passing the value to the caching component. However, this structure waits for the rendering of the entire page to complete, failing to leverage the benefits of Partial Prerender for partial rendering.
dynamic/component/awaited/1

suspended cache components
When implementing this pattern, ensure you resolve the Promise within the Suspense that wraps the cached component.

<Suspense fallback={<LoadingCard />}>
  {params.then(({ slug }) => (
    <Cached slug={slug} />
  ))}
</Suspense>

In contrast, if you pass params to the component as a Promise and extract the value inside using const ... = await params, it provides fallbacks per component and eliminates the need to wrap the whole page in Suspense. Therefore, this approach is generally better.
dynamic/component/1

dynamic page in cache remote

Differences between "use cache" and "use cache: remote"

So, what is the difference between "use cache" and "use cache: remote"? This comment was very helpful regarding this part.

Here is an explanation of that comment.
First, as a premise, the behavior of "use cache" and "use cache: remote" seems to differ depending on the deployment environment.
By default, both "use cache" and "use cache: remote" use memory as storage for caching. Therefore, they should behave the same way when running next start locally after building.

Local execution

Let's look at the same page as dynamic/component/1, which was built and run locally and also deployed to Vercel. While the component with "use cache" was not cached when deployed to Vercel, it is cached locally, and as expected, both "use cache" and "use cache: remote" exhibit the same behavior.

local

Third-party hosting providers can customize the behavior of "use cache" and "use cache: remote" to provide cache behavior integrated with their platform. Vercel is no exception and has its own customization. It also seems possible to create new directives like "use cache: xxx".
Since Vercel is a serverless environment, even if data is stored in memory during the first access, it cannot be referenced during subsequent accesses because they may run on a different server. Consequently, in-memory caching becomes meaningless.
For this reason, applications on Vercel do not cache anything in memory.
"use cache" is solely used to detect whether an element is pre-renderable and whether it's possible to prefetch for immediate navigation.
Elements with "use cache: remote" are cached in external storage and referenced from each serverless environment. This makes it possible to cache elements even within Dynamic Routing.

That is the reason why "use cache" does not cache elements within Dynamic Routing on Vercel, whereas "use cache: remote" makes caching possible. By the way, while it's mentioned that "use cache: remote" incurs costs, it seems to be available to some extent as it works in the verification app running on Vercel's hobby plan. I'm not entirely sure which usage metric it relates to, but I suspect it might be something like ISR Write/Read.

Summary

Since this was a verification of behavior on Vercel, I recommend testing it yourself before using it in other environments. I have implemented most patterns in the verification app, so feel free to use it.

Also, I didn't investigate it specifically as I didn't need to use it, but there is also a feature called "use cache: private" that allows caching on the client side. I'm sure someone else will explain that eventually.

GitHubで編集を提案

Discussion

koichikkoichik

検証および執筆ありがとうございます!
余計なお世話かと思いますが、少し気になるところがあるのでコメントさせていただきます

本記事の本題は「Dynamic Routingにおけるコンポーネントのキャッシュ」とのことですが、実際に書かれている内容はDynamic Routesを含めたDynamic APIsはほとんど (少なくとも直接的には) 関係ないと思います
本記事の検証結果は

  • Vercel環境でキャッシュしたければ"use cache"ではなく"use cache: remote"を使う

と要約できるかと思います
実際、「"use cache""use cache: remote"の違い」の「ローカル実行」に書かれているように、Dynamic Routesであってもローカル環境では"use cache: remote"と同じように"use cache"もキャッシュされていますよね
従ってVercel環境においてもDynamic Routesであることはキャッシュされてない直接の要因ではないと思うのです

それではDynamic Routesのparamsを使っていない場合 (「Without Parameters」) の<Cached>コンポーネントはなぜVercel環境でもキャッシュされているように見えるかというと、これは"use cache"ではなくPPRによるStatic Shellがキャッシュされているためだと思います
PPRと"use cache"は (加えてDynamicIOやSegment Cacheも) Cache Componentsとして統合されて一つのフラグでまとめて有効になりますが、元々は独立に開発されてきたものです
たとえば https://cache-test-silk.vercel.app/dynamic/component/1 を直接開いた場合のレスポンスHTMLを見ると、「Without Parameters」にある<Cached>コンポーネントの部分は以下のようにレンダリングされています

                <h2>Without Parameters</h2>
                <div class="card-module__0fl7Ca__root">
                    <div class="stack-module__yBCjnW__root stack-module__yBCjnW__horizontal">
                        <h2>Cached Dynamic Component</h2>
                        <button class="button-module__aLF8Ya__button">Purge Cache</button>
                    </div>
                    <p>
                        This component created at: 
                        <!-- -->
                        2026/1/6 9:53:25<br/>
                        <span>seconds ago.</span>
                    </p>
                </div>

この部分はPPRによるStatic Shellです (ローカル環境でnext buildすると.next/server/app/dynamic/component/[slug].htmlに生成されます)
"use cache"が指定された<Cached>コンポーネントだけでなく、"use cache"が指定されていないページコンポーネントの一部 (<h2>Without Parameters</h2>など) も含まれていることがわかります
Static Shellは"use cache"等の有無にかかわらず、コンポーネントを実行することなくレスポンスとして返せる部分 (<Suspense>の外側) です
(余談かつご存じかもですが、Static Shellでは<Suspense>の内側はフォールバックがレンダリングされた状態になってます)

つまり、<Cached>コンポーネントは ("use cache"が指定されていないページコンポーネントと同様に) Vercel環境ではキャッシュされていませんが、その実行結果を含むStatic Shellはキャッシュから返されるため、あたかも"use cache"が効いているように見えているだけだと思うのです

これを確認するには、たとえばページコンポーネントの「Without Parameters」のところを

      <Card>
        <h2>Without Parameters</h2>

        <Suspense fallback={<LoadingCard />}>
          <Cached />
        </Suspense>
        <Suspense fallback={<LoadingCard />}>
          <CachedRemote />
        </Suspense>
      </Card>

のように<Cached><Suspense>で囲みます
すると、この<Cached>はStatic Shellに含まれなくなるため、paramsが渡されていないにもかかわらず (Vercel環境では) コンポーネントとしてはキャッシュされないことが確認できるかと思います (毎回フォールバックが約5秒表示される)
一方、"use cache: remote"を指定した<CachedRemote>はこの場合でもキャッシュされるはずです
Vercel環境におけるコンポーネントレベルのキャッシュにはparamsやDynamic APIsを使うかどうかは影響がなく、"use cache""use cache: remote"のどちらを使っているかだけが影響することを確認できると思います

ここまでのまとめとして、

  • Dynamic APIsを使うかどうかに関係なく、Vercel環境では"use cache"はキャッシュされない
  • Dynamic APIsを使うかどうかに関係なく、Vercel環境でキャッシュしたければ"use cache: remote"を使う
  • PPRによるStatic Shellのキャッシュは"use cache"/"use cache: remote"とは独立している

となると思います

ここからもう少しややこしくなるのですが、PPRはキャッシュされたStatic Shellをレスポンスの一部として素早く返すわけですが、その裏ではStatic Shellに含まれる部分、すなわちレイアウトコンポーネントやページコンポーネント、それらの子コンポーネント等も ("use cache"等で実際にキャッシュされている場合を除き) 実行されます
つまり、https://cache-test-silk.vercel.app/dynamic/component/1 の「Without Parameters」にある<Cached>コンポーネント (Vercel環境ではキャッシュされていない) も実はリクエストの度に実行されているはずなのです
そしてその実行結果はレスポンスHTMLの後ろの方に出てくるRSCペイロードに含まれます
culr等で https://cache-test-silk.vercel.app/dynamic/component/1 にアクセスすると、レスポンスが途中で約5秒止まる様子を見ることができます
そして約5秒後に送られてくるレスポンス (Static Shellよりも後ろの部分) には「Without Parameters」の<Cached>の実行結果も含まれています (時刻の文字列で検索すると見つけられます)
<Cached>コンポーネントにconsole.log()などを仕込んだとしてVercel環境で見れるのかどうか知りませんが (Vercelは使ったことがないのです😅)、もし見ることができるなら該当のURLにアクセスする毎に<Cached>の実行ログは「Without Parameters」側と「With Parameters」側で2回ずつ出力されるはずです (もし可能であれば検証して頂けると嬉しいです)

Next.jsのキャッシュはCache Componentsで従来より洗練されたと思いますが、それでも

  • PPR (Static Shell) と"use cache" ("use cache: remote"等も含めた総称) の違い
  • "use cache""use cache: remote"の違い
  • セルフホスト環境とVercel環境の違い

などなど難しい部分がまだありますね
長いコメント失礼しました

108えん108えん

コメントありがとうございます!PPRの動作までは考慮できていませんでした。
いくつか追加で検証をしたのですが、考察までできていないので、結果だけお返しさせていただきます。

logの追加

各コンポーネントのレンダリングごとにログを出すように変更し、dynamic/component/1にアクセスしてみました。
結果として"use cache"のコンポーネントでログが2回出ることはなく、1回分だけでした。

みにくいかもしれないので、テキストログ
2026-01-11 14:35:19.167 [info] Cached component rendered with slug: 1 at 2026-01-11T14:35:19.166Z
2026-01-11 14:35:19.211 [info] Cached remote component rendered with slug: 1 at 2026-01-11T14:35:19.211Z

Suspenseの追加

パラメーターを渡さないコンポーネントをSuspenseで囲ったdynamic/component/suspended/1を新しく作成してアクセスしてみました。

結果、リロードごとに5秒待つことはなくすぐ表示される状態でした。(リロードはshift+cmd+r

koichikkoichik

検証ありがとうございます!
なんと…… 全く想定外の結果でした
つまりこの記事が正しくてDynamic APIsを使っていなければ"use cache"はVercel環境であってもキャッシュされるということなんですね……
Vercelで試すこともなく推測でコメントしてしまい申し訳ありませんでした

しかし、これだとローカル環境とVercel環境の挙動の違いをcacheHandlers.defaultの実装だけで説明するのは難しい気がします
cacheHandlers.defaultからDynamic APIsの呼び出しの有無を取得できるようなAPIになっているようには見えないので……