Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Next.js 16 Cache Components - PPR, use cache directive, cacheLife, cacheTag, updateTag
.claude/skills/leoyeai-next-cache-components/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 63% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 71% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 141% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 49% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 113% | 0% |
Cache Components enable Partial Prerendering (PPR) - mix static, cached, and dynamic content in a single route.
ts// next.config.ts import type { NextConfig } from 'next' const nextConfig: NextConfig = { cacheComponents: true, } export default nextConfig
This replaces the old experimental.ppr flag.
With Cache Components enabled, content falls into three categories:
Synchronous code, imports, pure computations - prerendered at build time:
tsxexport default function Page() { return ( <header> <h1>Our Blog</h1> {/* Static - instant */} <nav>...</nav> </header> ) }
use cache)Async data that doesn't need fresh fetches every request:
tsxasync function BlogPosts() { 'use cache' cacheLife('hours') const posts = await db.posts.findMany() return <PostList posts={posts} /> }
Runtime data that must be fresh - wrap in Suspense:
tsximport { Suspense } from 'react' export default function Page() { return ( <> <BlogPosts /> {/* Cached */} <Suspense fallback={<p>Loading...</p>}> <UserPreferences /> {/* Dynamic - streams in */} </Suspense> </> ) } async function UserPreferences() { const theme = (await cookies()).get('theme')?.value return <p>Theme: {theme}</p> }
use cache Directivetsx'use cache' export default async function Page() { // Entire page is cached const data = await fetchData() return <div>{data}</div> }
tsxexport async function CachedComponent() { 'use cache' const data = await fetchData() return <div>{data}</div> }
tsxexport async function getData() { 'use cache' return db.query('SELECT * FROM posts') }
tsx'use cache' // Default: 5m stale, 15m revalidate
tsx'use cache: remote' // Platform-provided cache (Redis, KV)
tsx'use cache: private' // For compliance, allows runtime APIs
cacheLife() - Custom Lifetimetsximport { cacheLife } from 'next/cache' async function getData() { 'use cache' cacheLife('hours') // Built-in profile return fetch('/api/data') }
Built-in profiles: 'default', 'minutes', 'hours', 'days', 'weeks', 'max'
tsxasync function getData() { 'use cache' cacheLife({ stale: 3600, // 1 hour - serve stale while revalidating revalidate: 7200, // 2 hours - background revalidation interval expire: 86400, // 1 day - hard expiration }) return fetch('/api/data') }
cacheTag() - Tag Cached Contenttsximport { cacheTag } from 'next/cache' async function getProducts() { 'use cache' cacheTag('products') return db.products.findMany() } async function getProduct(id: string) { 'use cache' cacheTag('products', `product-${id}`) return db.products.findUnique({ where: { id } }) }
updateTag() - Immediate InvalidationUse when you need the cache refreshed within the same request:
tsx'use server' import { updateTag } from 'next/cache' export async function updateProduct(id: string, data: FormData) { await db.products.update({ where: { id }, data }) updateTag(`product-${id}`) // Immediate - same request sees fresh data }
revalidateTag() - Background RevalidationUse for stale-while-revalidate behavior:
tsx'use server' import { revalidateTag } from 'next/cache' export async function createPost(data: FormData) { await db.posts.create({ data }) revalidateTag('posts') // Background - next request sees fresh data }
Cannot access cookies(), headers(), or searchParams inside use cache.
tsx// Wrong - runtime API inside use cache async function CachedProfile() { 'use cache' const session = (await cookies()).get('session')?.value // Error! return <div>{session}</div> } // Correct - extract outside, pass as argument async function ProfilePage() { const session = (await cookies()).get('session')?.value return <CachedProfile sessionId={session} /> } async function CachedProfile({ sessionId }: { sessionId: string }) { 'use cache' // sessionId becomes part of cache key automatically const data = await fetchUserData(sessionId) return <div>{data.name}</div> }
use cache: privateFor compliance requirements when you can't refactor:
tsxasync function getData() { 'use cache: private' const session = (await cookies()).get('session')?.value // Allowed return fetchData(session) }
Cache keys are automatic based on:
tsxasync function Component({ userId }: { userId: string }) { const getData = async (filter: string) => { 'use cache' // Cache key = userId (closure) + filter (argument) return fetch(`/api/users/${userId}?filter=${filter}`) } return getData('active') }
tsximport { Suspense } from 'react' import { cookies } from 'next/headers' import { cacheLife, cacheTag } from 'next/cache' export default function DashboardPage() { return ( <> {/* Static shell - instant from CDN */} <header><h1>Dashboard</h1></header> <nav>...</nav> {/* Cached - fast, revalidates hourly */} <Stats /> {/* Dynamic - streams in with fresh data */} <Suspense fallback={<NotificationsSkeleton />}> <Notifications /> </Suspense> </> ) } async function Stats() { 'use cache' cacheLife('hours') cacheTag('dashboard-stats') const stats = await db.stats.aggregate() return <StatsDisplay stats={stats} /> } async function Notifications() { const userId = (await cookies()).get('userId')?.value const notifications = await db.notifications.findMany({ where: { userId, read: false } }) return <NotificationList items={notifications} /> }
| Old Config | Replacement | |-----------|-------------| | experimental.ppr | cacheComponents: true | | dynamic = 'force-dynamic' | Remove (default behavior) | | dynamic = 'force-static' | 'use cache' + cacheLife('max') | | revalidate = N | cacheLife({ revalidate: N }) | | unstable_cache() | 'use cache' directive |
unstable_cache to use cacheunstable_cache has been replaced by the use cache directive in Next.js 16. When cacheComponents is enabled, convert unstable_cache calls to use cache functions:
Before (unstable_cache):
tsximport { unstable_cache } from 'next/cache' const getCachedUser = unstable_cache( async (id) => getUser(id), ['my-app-user'], { tags: ['users'], revalidate: 60, } ) export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params const user = await getCachedUser(id) return <div>{user.name}</div> }
After (use cache):
tsximport { cacheLife, cacheTag } from 'next/cache' async function getCachedUser(id: string) { 'use cache' cacheTag('users') cacheLife({ revalidate: 60 }) return getUser(id) } export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params const user = await getCachedUser(id) return <div>{user.name}</div> }
Key differences:
use cache generates keys automatically from function arguments and closures. The keyParts array from unstable_cache is no longer needed.options.tags with cacheTag() calls inside the function.options.revalidate with cacheLife({ revalidate: N }) or a built-in profile like cacheLife('minutes').unstable_cache did not support cookies() or headers() inside the callback. The same restriction applies to use cache, but you can use 'use cache: private' if needed.Math.random(), Date.now()) execute once at build time inside use cacheFor request-time randomness outside cache:
tsximport { connection } from 'next/server' async function DynamicContent() { await connection() // Defer to request time const id = crypto.randomUUID() // Different per request return <div>{id}</div> }
Sources:
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 14,105 | 11,998 | -15% | 1 | 1 | 0% | 3,128 | 5,094 | +63% | 0 | 0 | — |
case-02 | pass→pass | 12,312 | 9,045 | -27% | 1 | 1 | 0% | 2,266 | 4,458 | +97% | 0 | 0 | — |
case-03 | fail→pass | 17,453 | 14,332 | -18% | 1 | 1 | 0% | 3,101 | 5,317 | +71% | 0 | 0 | — |
case-04 | fail→pass | 7,265 | 2,150 | -70% | 1 | 1 | 0% | 1,221 | 2,939 | +141% | 0 | 0 | — |
case-05 | pass→pass | 8,707 | 10,444 | +20% | 1 | 1 | 0% | 1,598 | 3,522 | +120% | 0 | 0 | — |
case-06 | pass→pass | 15,675 | 4,938 | -68% | 1 | 1 | 0% | 2,809 | 3,466 | +23% | 0 | 0 | — |
case-07 | pass→pass | 12,605 | 8,000 | -37% | 1 | 1 | 0% | 2,553 | 4,114 | +61% | 0 | 0 | — |
case-08 | fail→pass | 13,094 | 3,651 | -72% | 1 | 1 | 0% | 2,064 | 3,078 | +49% | 0 | 0 | — |
case-09 | pass→pass | 11,925 | 8,291 | -30% | 1 | 1 | 0% | 1,953 | 3,780 | +94% | 0 | 0 | — |
case-10 | pass→pass | 12,542 | 7,779 | -38% | 1 | 1 | 0% | 2,294 | 4,043 | +76% | 0 | 0 | — |
case-11 | pass→pass | 5,925 | 6,761 | +14% | 1 | 1 | 0% | 1,022 | 3,645 | +257% | 0 | 0 | — |
case-12 | fail→pass | 8,883 | 4,262 | -52% | 1 | 1 | 0% | 1,576 | 3,355 | +113% | 0 | 0 | — |
case-13 | pass→pass | 12,693 | 8,972 | -29% | 1 | 1 | 0% | 2,197 | 4,074 | +85% | 0 | 0 | — |
case-14 | pass→pass | 12,722 | 9,908 | -22% | 1 | 1 | 0% | 2,473 | 4,519 | +83% | 0 | 0 | — |
case-15 | fail→fail | 9,222 | 5,610 | -39% | 1 | 1 | 0% | 1,713 | 3,669 | +114% | 0 | 0 | — |
case-16 | pass→pass | 14,462 | 6,929 | -52% | 1 | 1 | 0% | 2,341 | 3,758 | +61% | 0 | 0 | — |
case-17 | pass→pass | 10,858 | 3,848 | -65% | 1 | 1 | 0% | 1,868 | 3,157 | +69% | 0 | 0 | — |
case-18 | fail→pass | 10,062 | 2,298 | -77% | 1 | 1 | 0% | 1,751 | 2,968 | +70% | 0 | 0 | — |
case-19 | pass→pass | 10,379 | 7,335 | -29% | 1 | 1 | 0% | 2,047 | 4,023 | +97% | 0 | 0 | — |
case-20 | pass→pass | 10,822 | 7,353 | -32% | 1 | 1 | 0% | 2,227 | 3,947 | +77% | 0 | 0 | — |
case-21 | fail→pass | 6,957 | 4,132 | -41% | 1 | 1 | 0% | 1,427 | 3,327 | +133% | 0 | 0 | — |
case-22 | pass→pass | 2,654 | 2,887 | +9% | 1 | 1 | 0% | 511 | 3,119 | +510% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +32 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.