Skip to content
Merged
2 changes: 2 additions & 0 deletions docs/01-app/01-getting-started/06-cache-components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,8 @@ export default async function Page() {
}
```

> **Good to know:** A cache is considered "short-lived" when it uses the `seconds` profile, `revalidate: 0`, or `expire` under 5 minutes. Short-lived caches are automatically excluded from prerenders and become dynamic holes instead. If such a cache is nested inside another `use cache` without an explicit `cacheLife`, Next.js will throw an error during prerendering to prevent accidental misconfigurations. See [Prerendering behavior](/docs/app/api-reference/functions/cacheLife#prerendering-behavior) for details.

See the [`cacheLife` API reference](/docs/app/api-reference/functions/cacheLife) for available profiles and custom configuration options.

### With runtime data
Expand Down
96 changes: 96 additions & 0 deletions docs/01-app/03-api-reference/04-functions/cacheLife.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,12 @@ When you call revalidation functions from a Server Action ([`revalidateTag`](/do

> **Good to know**: The `stale` property in `cacheLife` differs from [`staleTimes`](/docs/app/api-reference/config/next-config-js/staleTimes). While `staleTimes` is a global setting affecting all routes, `cacheLife` allows per-function or per-route configuration. Updating `staleTimes.static` also updates the `stale` value of the `default` cache profile.

### Prerendering behavior

Caches with very short lifetimes — zero `revalidate` or `expire` under 5 minutes — are automatically excluded from prerenders and become "dynamic holes" instead. This includes the `seconds` profile.

This behavior allows you to mix static and dynamic content within the same page. Static parts are prerendered, while short-lived caches create boundaries where data is fetched at request time rather than build time. Use a `<Suspense>` boundary around dynamic caches to provide a fallback while content loads.

## Examples

### Using preset profiles
Expand Down Expand Up @@ -420,6 +426,96 @@ export default async function Dashboard() {

**It is recommended to specify an explicit `cacheLife`.** With explicit lifetime values, you can inspect a cached function or component and immediately know its behavior without tracing through nested caches. Without explicit lifetime values, the behavior becomes dependent on inner cache lifetimes, making it harder to reason about.

#### Nested short-lived caches

As described in [Prerendering behavior](#prerendering-behavior), short-lived caches (zero `revalidate` or `expire` under 5 minutes) become dynamic holes excluded from prerenders.

When a short-lived cache is nested inside another `use cache` without an explicit `cacheLife`, the outer cache's lifetime would silently become short too via propagation. To prevent this accidental misconfiguration, Next.js throws an error during prerendering.

Note that the nested cache may not be obvious — it could be in an imported module or even a third-party dependency:

```tsx filename="components/short-lived-widget.tsx" highlight={5}
import { cacheLife } from 'next/cache'

export async function ShortLivedWidget() {
'use cache'
cacheLife('seconds')
const data = await fetchRealtimeData()
return <div>{data}</div>
}
```

Using this component from another `use cache` without an explicit `cacheLife` will error during prerendering:

```tsx filename="app/page.tsx"
import { ShortLivedWidget } from '@/components/short-lived-widget'

export default async function Page() {
'use cache'
// Error: no explicit cacheLife on outer cache
return (
<div>
<h1>Dashboard</h1>
<p>Last updated: {new Date().toISOString()}</p>
<ShortLivedWidget />
</div>
)
}
```

To fix the error, add an explicit `cacheLife()` to the outer `use cache`:

**If you want the outer cache to remain static (prerendered)**, set a longer cache lifetime:

```tsx filename="app/page.tsx" highlight={6}
import { cacheLife } from 'next/cache'
import { ShortLivedWidget } from '@/components/short-lived-widget'

export default async function Page() {
'use cache'
cacheLife('default') // Explicit cacheLife prevents the error
return (
<div>
<h1>Dashboard</h1>
<p>Last updated: {new Date().toISOString()}</p>
<ShortLivedWidget />
</div>
)
}
```

**If you want the outer cache to also be short-lived**, explicitly set a short cache lifetime to confirm this is intentional. Wrap the component in a `<Suspense>` boundary to provide a fallback while content loads:

```tsx filename="app/page.tsx" highlight={7,17-19}
import { Suspense } from 'react'
import { cacheLife } from 'next/cache'
import { ShortLivedWidget } from '@/components/short-lived-widget'

async function Content() {
'use cache: remote'
cacheLife('seconds') // Explicit cacheLife confirms this is intentionally short-lived
return (
<>
<p>Last updated: {new Date().toISOString()}</p>
<ShortLivedWidget />
</>
)
}

export default function Page() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading...</p>}>
<Content />
</Suspense>
</div>
)
}
```

> **Note:** This example uses `"use cache: remote"` because runtime caching in serverless deployments doesn't persist across requests with the default in-memory cache. For self-hosted environments, `"use cache"` may be sufficient. See [Runtime caching considerations](/docs/app/api-reference/directives/use-cache#runtime-caching-considerations) for more details.

### Conditional cache lifetimes

You can call `cacheLife` conditionally in different code paths to set different cache durations based on your application logic:
Expand Down
106 changes: 106 additions & 0 deletions errors/nested-use-cache-no-explicit-cachelife.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
---
title: Nested `"use cache"` with short cache lifetime requires explicit `cacheLife` on outer cache
---

## Why This Error Occurred

A `"use cache"` function or component with a very short cache lifetime (either `revalidate: 0` or `expire` under 5 minutes) is nested inside another `"use cache"` that doesn't have an explicit `cacheLife()` call.

When a nested cache has a very short lifetime, it would normally create a "dynamic hole" - meaning it's excluded from static prerenders. However, when this happens inside another `"use cache"` without an explicit `cacheLife`, the outer cache's lifetime silently becomes very short too (via propagation), which may be unintentional.

To prevent accidental misconfigurations, Next.js requires you to explicitly declare your intent by adding `cacheLife()` to the outer `"use cache"`.

## Possible Ways to Fix It

Add an explicit `cacheLife()` call to the outer `"use cache"` to declare your intent.

### Before

```jsx filename="components/short-lived-widget.js"
import { cacheLife } from 'next/cache'

export async function ShortLivedWidget() {
'use cache'
cacheLife('seconds')
const data = await fetchRealtimeData()
return <div>{data}</div>
}
```

```jsx filename="app/page.js"
import { ShortLivedWidget } from '@/components/short-lived-widget'

export default async function Page() {
'use cache'
// Error: no explicit cacheLife on outer cache
return (
<div>
<h1>Dashboard</h1>
<p>Last updated: {new Date().toISOString()}</p>
<ShortLivedWidget />
</div>
)
}
```

### After: If you want the outer cache to remain static (prerendered)

Set a longer cache lifetime on the outer cache:

```jsx filename="app/page.js" highlight={6}
import { cacheLife } from 'next/cache'
import { ShortLivedWidget } from '@/components/short-lived-widget'

export default async function Page() {
'use cache'
cacheLife('default') // Explicit cacheLife prevents the error
return (
<div>
<h1>Dashboard</h1>
<p>Last updated: {new Date().toISOString()}</p>
<ShortLivedWidget />
</div>
)
}
```

### After: If you want the outer cache to also be short-lived

Explicitly set a short cache lifetime on the outer cache to confirm this is intentional. Wrap the component in a `<Suspense>` boundary to provide a fallback while content loads:

```jsx filename="app/page.js" highlight={7,17-19}
import { Suspense } from 'react'
import { cacheLife } from 'next/cache'
import { ShortLivedWidget } from '@/components/short-lived-widget'

async function Content() {
'use cache: remote'
cacheLife('seconds') // Explicit cacheLife confirms this is intentionally short-lived
return (
<>
<p>Last updated: {new Date().toISOString()}</p>
<ShortLivedWidget />
</>
)
}

export default function Page() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading...</p>}>
<Content />
</Suspense>
</div>
)
}
```

> **Note:** This example uses `"use cache: remote"` because runtime caching in serverless deployments doesn't persist across requests with the default in-memory cache. For self-hosted environments, `"use cache"` may be sufficient. See [Runtime caching considerations](/docs/app/api-reference/directives/use-cache#runtime-caching-considerations) for more details.

## Useful Links

- [`cacheLife` function](/docs/app/api-reference/functions/cacheLife)
- [`"use cache"` directive](/docs/app/api-reference/directives/use-cache)
- [Prerendering behavior](/docs/app/api-reference/functions/cacheLife#prerendering-behavior)
- [Nested short-lived caches](/docs/app/api-reference/functions/cacheLife#nested-short-lived-caches)
18 changes: 11 additions & 7 deletions packages/next/src/server/app-render/postponed-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,16 @@ describe('getDynamicHTMLPostponedState', () => {
prerenderResumeDataCache.cache.set(
'1',
Promise.resolve({
value: streamFromString('hello'),
tags: [],
stale: 0,
timestamp: 0,
expire: 300,
revalidate: 1,
entry: {
value: streamFromString('hello'),
tags: [],
stale: 0,
timestamp: 0,
expire: 300,
revalidate: 1,
},
hasExplicitRevalidate: true,
hasExplicitExpire: true,
})
)

Expand Down Expand Up @@ -80,7 +84,7 @@ describe('getDynamicHTMLPostponedState', () => {

expect(value).toBeDefined()

await expect(streamToString(value!.value)).resolves.toEqual('hello')
await expect(streamToString(value!.entry.value)).resolves.toEqual('hello')
})

it('serializes a HTML postponed state without fallback params', async () => {
Expand Down
80 changes: 46 additions & 34 deletions packages/next/src/server/resume-data-cache/cache-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import {
arrayBufferToString,
stringToUint8Array,
} from '../app-render/encryption-utils'
import type { CacheEntry } from '../lib/cache-handlers/types'
import type { CachedFetchValue } from '../response-cache/types'
import { DYNAMIC_EXPIRE } from '../use-cache/constants'
import type { CollectedCacheResult } from '../use-cache/use-cache-wrapper'

/**
* A generic cache store type that provides a subset of Map functionality
Expand Down Expand Up @@ -34,19 +34,23 @@ export type DecryptedBoundArgsCacheStore = CacheStore<string>
* Serialized format for "use cache" entries
*/
export interface UseCacheCacheStoreSerialized {
value: string
tags: string[]
stale: number
timestamp: number
expire: number
revalidate: number
entry: {
value: string
tags: string[]
stale: number
timestamp: number
expire: number
revalidate: number
}
hasExplicitRevalidate: boolean | undefined
hasExplicitExpire: boolean | undefined
}

/**
* A cache store specifically for "use cache" values that stores promises of
* cache entries.
* collected cache results (entry + metadata).
*/
export type UseCacheCacheStore = CacheStore<Promise<CacheEntry>>
export type UseCacheCacheStore = CacheStore<Promise<CollectedCacheResult>>

/**
* Parses serialized cache entries into a UseCacheCacheStore
Expand All @@ -56,30 +60,34 @@ export type UseCacheCacheStore = CacheStore<Promise<CacheEntry>>
export function parseUseCacheCacheStore(
entries: Iterable<[string, UseCacheCacheStoreSerialized]>
): UseCacheCacheStore {
const store = new Map<string, Promise<CacheEntry>>()
const store = new Map<string, Promise<CollectedCacheResult>>()

for (const [
key,
{ value, tags, stale, timestamp, expire, revalidate },
{ entry, hasExplicitRevalidate, hasExplicitExpire },
] of entries) {
store.set(
key,
Promise.resolve({
// Create a ReadableStream from the Uint8Array
value: new ReadableStream<Uint8Array>({
start(controller) {
// Enqueue the Uint8Array to the stream
controller.enqueue(stringToUint8Array(atob(value)))
entry: {
// Create a ReadableStream from the Uint8Array
value: new ReadableStream<Uint8Array>({
start(controller) {
// Enqueue the Uint8Array to the stream
controller.enqueue(stringToUint8Array(atob(entry.value)))

// Close the stream
controller.close()
},
}),
tags,
stale,
timestamp,
expire,
revalidate,
// Close the stream
controller.close()
},
}),
tags: entry.tags,
stale: entry.stale,
timestamp: entry.timestamp,
expire: entry.expire,
revalidate: entry.revalidate,
},
hasExplicitRevalidate,
hasExplicitExpire,
})
)
}
Expand All @@ -93,13 +101,13 @@ export function parseUseCacheCacheStore(
* @returns A promise that resolves to an array of key-value pairs with serialized values
*/
export async function serializeUseCacheCacheStore(
entries: IterableIterator<[string, Promise<CacheEntry>]>,
entries: IterableIterator<[string, Promise<CollectedCacheResult>]>,
isCacheComponentsEnabled: boolean
): Promise<Array<[string, UseCacheCacheStoreSerialized] | null>> {
return Promise.all(
Array.from(entries).map(([key, value]) => {
return value
.then(async (entry) => {
.then(async ({ entry, hasExplicitRevalidate, hasExplicitExpire }) => {
if (
isCacheComponentsEnabled &&
(entry.revalidate === 0 || entry.expire < DYNAMIC_EXPIRE)
Expand All @@ -124,13 +132,17 @@ export async function serializeUseCacheCacheStore(
return [
key,
{
// Encode the value as a base64 string.
value: btoa(binaryString),
tags: entry.tags,
stale: entry.stale,
timestamp: entry.timestamp,
expire: entry.expire,
revalidate: entry.revalidate,
entry: {
// Encode the value as a base64 string.
value: btoa(binaryString),
tags: entry.tags,
stale: entry.stale,
timestamp: entry.timestamp,
expire: entry.expire,
revalidate: entry.revalidate,
},
hasExplicitRevalidate,
hasExplicitExpire,
},
] satisfies [string, UseCacheCacheStoreSerialized]
})
Expand Down
Loading
Loading