SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Apr 4, 2026·Tutorial·SecureStartKit Team

Next.js 'use cache' Directive: Complete Guide [2026]

Next.js 16 replaced implicit caching with opt-in 'use cache'. Learn the three directives, cacheLife profiles, and real SaaS patterns.

Summarize with AI

On this page

  • Table of Contents
  • What changed from the old caching model?
  • How does "use cache" work?
  • Enabling Cache Components
  • The three cache directives explained
  • "use cache" (default)
  • "use cache: remote"
  • "use cache: private"
  • The critical rule about runtime APIs
  • How to control cache lifetime with cacheLife
  • Built-in profiles
  • Custom profiles
  • Tag-based invalidation with cacheTag
  • Real caching patterns for a SaaS app
  • Marketing pages: cache aggressively
  • Blog posts: static with tag invalidation
  • Dashboard: dynamic with a cached shell
  • Server Action invalidation: revalidatePath still works
  • Common mistakes that break caching
  • What this means for your Next.js app

On this page

  • Table of Contents
  • What changed from the old caching model?
  • How does "use cache" work?
  • Enabling Cache Components
  • The three cache directives explained
  • "use cache" (default)
  • "use cache: remote"
  • "use cache: private"
  • The critical rule about runtime APIs
  • How to control cache lifetime with cacheLife
  • Built-in profiles
  • Custom profiles
  • Tag-based invalidation with cacheTag
  • Real caching patterns for a SaaS app
  • Marketing pages: cache aggressively
  • Blog posts: static with tag invalidation
  • Dashboard: dynamic with a cached shell
  • Server Action invalidation: revalidatePath still works
  • Common mistakes that break caching
  • What this means for your Next.js app

Next.js 16 removed implicit caching entirely. Every page, component, and function now runs dynamically at request time by default. If you want something cached, you explicitly opt in with the "use cache" directive [1]. This is the opposite of how caching worked before, where the framework cached aggressively and developers spent time figuring out how to opt out.

The change is significant. If you're upgrading from Next.js 14 or 15, your app's caching behavior will change. Pages that were silently cached at build time now render on every request unless you tell them not to. This guide covers how the three cache directives work, when to use each one, and what this looks like in a real SaaS codebase.

Table of Contents

  • What changed from the old caching model?
  • How does "use cache" work?
  • The three cache directives explained
  • How to control cache lifetime with cacheLife
  • Tag-based invalidation with cacheTag
  • Real caching patterns for a SaaS app
  • Common mistakes that break caching
  • What this means for your Next.js app

What changed from the old caching model?

In Next.js 14, the framework cached fetch responses by default. You had to pass { cache: 'no-store' } to every fetch call that needed fresh data. The revalidate export on page segments controlled how often static pages regenerated. Most of this happened implicitly, which made caching behavior hard to predict and debug.

Next.js 16 flipped this model [2]. Here's what changed:

  • No implicit fetch caching. Fetch calls are no longer cached by default. Every request hits the origin unless you opt in.
  • No implicit page caching. Pages render dynamically at request time. Static generation only happens for pages that explicitly use generateStaticParams() or "use cache".
  • The revalidate export is gone. Use cacheLife() inside a "use cache" scope instead.
  • The dynamic export is deprecated. dynamic = 'force-static' becomes "use cache" with cacheLife('max').

The old mental model: everything is cached, opt out where needed. The new mental model: nothing is cached, opt in where it helps.

How does "use cache" work?

The "use cache" directive is a string you place at the top of an async function, component, or file. It tells the Next.js compiler to cache the return value of that function. The compiler automatically generates cache keys from the function's arguments, so you don't manage keys manually [1].

You can place it at three levels:

File-level (caches all exports):

'use cache'

export default async function BlogIndex() {
  const posts = await getAllPosts()
  return <PostList posts={posts} />
}

Component-level (caches one component):

async function ProductList() {
  'use cache'
  const products = await db.product.findMany()
  return <List items={products} />
}

Function-level (caches a data-fetching function):

async function getCategories() {
  'use cache'
  return db.categories.findMany()
}

When Next.js encounters "use cache", it precomputes the result and serves it as static HTML on subsequent requests. This generates what Next.js calls a static shell: the cached portion of the page renders instantly, while any dynamic content (wrapped in <Suspense>) streams in after [3].

This is Partial Prerendering (PPR), and it's the default rendering behavior when you use Cache Components. Your landing page can be fully cached. Your dashboard can have a cached layout with dynamic data streaming into Suspense boundaries.

Enabling Cache Components

Before using any cache directive, enable it in your Next.js config:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

The three cache directives explained

Next.js provides three variants of the cache directive. Each stores cached data differently and has different rules about what APIs you can access inside it [1].

DirectiveStorageShared between users?Can access cookies/headers?Best for
"use cache"In-memory LRUYesNoStatic pages, shared data
"use cache: remote"Remote cache handlerYesNoServerless, shared runtime data
"use cache: private"Browser memory onlyNo (per-user)YesPersonalized content

"use cache" (default)

The standard directive. Cached results are stored in an in-memory LRU cache on the server. In serverless environments, this cache doesn't persist between cold starts, so entries may be recomputed more often than expected.

Use this for content that's the same for all visitors: marketing pages, blog posts, pricing tables, documentation.

async function getBlogPost(slug: string) {
  'use cache'
  // Cached in-memory, shared across all users
  return getPostBySlug(slug)
}

"use cache: remote"

Stores cached results in a remote cache handler that persists across serverless function instances. All users share the same cache entries. This is the better choice for serverless deployments when you need runtime caching (not just build-time), because the cache survives cold starts [1].

The tradeoff: remote cache lookups add a small amount of latency compared to in-memory reads.

async function getProductPrice(id: string, currency: string) {
  'use cache: remote'
  cacheLife({ expire: 300 }) // 5 minutes
  // Cached remotely, shared across all serverless instances
  return db.products.getPrice(id, currency)
}

"use cache: private"

This is the only directive that can access cookies(), headers(), and searchParams directly. Results are cached per-user in the browser's memory and never stored on the server. The cache doesn't persist across page reloads.

Use this for personalized content: user dashboards, recommendation feeds, anything that depends on who's logged in.

import { cookies } from 'next/headers'

async function getRecommendations(productId: string) {
  'use cache: private'
  cacheLife({ expire: 60 })

  const sessionId = (await cookies()).get('session-id')?.value
  return db.recommendations.findMany({ where: { productId, sessionId } })
}

The critical rule about runtime APIs

With "use cache" and "use cache: remote", you cannot call cookies(), headers(), or read searchParams inside the cached scope. These are request-specific values that change per user, so they can't be part of a shared cache entry.

If your cached function needs user-specific data, either use "use cache: private" or pass the value in as an argument:

// This breaks: cookies() inside a shared cache
async function getData() {
  'use cache'
  const token = (await cookies()).get('token') // Error
}

// This works: pass the value as an argument
async function getData(token: string) {
  'use cache'
  return db.query({ where: { token } })
}

How to control cache lifetime with cacheLife

By default, cached entries don't have a defined expiration. The cacheLife function lets you control how long cached content stays fresh [4].

Built-in profiles

Next.js ships several named profiles:

import { cacheLife } from 'next/cache'

async function getStaticContent() {
  'use cache'
  cacheLife('max')      // Cache indefinitely (until manual invalidation)
  return fetchContent()
}

async function getBlogPosts() {
  'use cache'
  cacheLife('days')     // Revalidate after 1 day
  return getAllPosts()
}

async function getPricing() {
  'use cache'
  cacheLife('hours')    // Revalidate after 1 hour
  return fetchPricing()
}

Custom profiles

Define your own profiles in next.config.ts with three timing properties:

  • stale: How long the client can use a cached value without checking the server
  • revalidate: How often the server refreshes the cache in the background
  • expire: Maximum age before the entry is dropped entirely
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheLife: {
    blog: {
      stale: 3600,      // Serve stale for 1 hour
      revalidate: 900,  // Revalidate every 15 minutes
      expire: 86400,    // Drop after 1 day
    },
    pricing: {
      stale: 60,
      revalidate: 30,
      expire: 3600,
    },
  },
}

export default nextConfig

Then reference the profile by name:

import { cacheLife } from 'next/cache'

async function getBlogPosts() {
  'use cache'
  cacheLife('blog')
  return getAllPosts()
}

Tag-based invalidation with cacheTag

The revalidatePath() function you might already use invalidates everything on a given path. cacheTag gives you finer control: tag individual cache entries, then invalidate just those entries when data changes [1].

import { cacheTag } from 'next/cache'

async function getPost(slug: string) {
  'use cache'
  cacheTag(`post-${slug}`)
  return getPostBySlug(slug)
}

When the post is updated, invalidate only its cache entry:

'use server'

import { revalidateTag } from 'next/cache'

export async function updatePost(slug: string, data: PostInput) {
  await db.posts.update({ where: { slug }, data })
  revalidateTag(`post-${slug}`)
}

This is more precise than revalidatePath('/blog'), which would flush the cache for the entire blog section. With tags, updating one post doesn't force every other cached post to regenerate.

Real caching patterns for a SaaS app

Here's how these directives map to the typical pages in a SaaS application. These patterns come from building SecureStartKit, where marketing pages serve thousands of visitors while the dashboard handles authenticated, per-user data.

Marketing pages: cache aggressively

Your landing page, pricing page, and blog don't change per user. Cache the entire page with a long lifetime:

// app/(marketing)/page.tsx
import { cacheLife } from 'next/cache'

export default async function HomePage() {
  'use cache'
  cacheLife('days')

  return (
    <main>
      <Hero />
      <Features />
      <Pricing />
      <Testimonials />
    </main>
  )
}

Blog posts: static with tag invalidation

Blog posts are a natural fit for "use cache" combined with generateStaticParams. Pre-render at build time, then invalidate individual posts when content changes:

// app/(marketing)/blog/[slug]/page.tsx
import { cacheLife, cacheTag } from 'next/cache'

export async function generateStaticParams() {
  const posts = getAllPosts()
  return posts.map((post) => ({ slug: post.slug }))
}

export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  'use cache'
  const { slug } = await params
  cacheTag(`blog-${slug}`)
  cacheLife('blog')

  const post = getPostBySlug(slug)
  if (!post) notFound()

  return <Article post={post} />
}

Dashboard: dynamic with a cached shell

The dashboard layout (sidebar, navigation) is the same for every authenticated user. The content is personalized. Use Suspense to separate the static shell from dynamic data:

// app/(dashboard)/page.tsx
import { Suspense } from 'react'

// The layout can be cached (it's the same for everyone)
export default function DashboardPage() {
  return (
    <div>
      <DashboardHeader />
      <Suspense fallback={<StatsSkeleton />}>
        <UserStats />
      </Suspense>
      <Suspense fallback={<ActivitySkeleton />}>
        <RecentActivity />
      </Suspense>
    </div>
  )
}

Server Action invalidation: revalidatePath still works

If you're already using revalidatePath() in your Server Actions, it still works with Cache Components. When a user updates their profile or completes a purchase, invalidate the relevant paths:

'use server'

import { revalidatePath } from 'next/cache'

export async function updateProfile(data: ProfileInput) {
  // ... validate and save
  revalidatePath('/settings')
  return { success: true }
}

For more granular control, switch from revalidatePath to revalidateTag. Both work, but tags let you invalidate specific data without flushing entire page caches.

Common mistakes that break caching

After working with Cache Components across multiple Next.js 16 projects, these are the patterns that trip people up most often.

1. Calling cookies() inside "use cache"

This is the most common error. The cookies() and headers() functions are request-specific. Calling them inside a shared cache scope will throw a build error. Either switch to "use cache: private" or pass the value as a function argument [1].

2. Forgetting to enable cacheComponents

The "use cache" directive does nothing without cacheComponents: true in your next.config.ts. If your pages are still rendering dynamically after adding the directive, check your config first.

3. Using "use cache" on pages with authentication checks

If your page reads a session cookie to verify the user, you can't cache it with the default directive. The auth check happens at request time. Instead, cache the data-fetching functions individually, and let the page itself remain dynamic:

// Don't cache the whole page if it checks auth
export default async function SettingsPage() {
  const user = await getUser() // reads cookies

  return (
    <div>
      <h1>Settings</h1>
      <Suspense fallback={<Loading />}>
        <CachedSettings userId={user.id} />
      </Suspense>
    </div>
  )
}

// Cache the data fetch instead
async function CachedSettings({ userId }: { userId: string }) {
  'use cache'
  cacheTag(`settings-${userId}`)
  const settings = await db.settings.findUnique({ where: { userId } })
  return <SettingsForm settings={settings} />
}

4. Expecting serverless cache persistence with "use cache"

The default "use cache" uses in-memory storage. In serverless environments (Vercel, AWS Lambda), this cache is lost on cold starts. If you need the cache to persist across function instances, use "use cache: remote" instead.

5. Over-caching sensitive data

Cache Components don't know about your security model. If you cache a function that returns user-specific data with the shared "use cache" directive, that data could be served to other users. Always use "use cache: private" for personalized content, and verify that cached functions don't leak data across users.

What this means for your Next.js app

The shift from implicit to explicit caching is the most significant developer experience change in Next.js 16. If your app relies on the old model (where pages were cached by default), upgrading means every page will start rendering dynamically. Performance may drop until you explicitly add "use cache" where it makes sense.

The practical migration path:

  1. Enable cacheComponents: true in next.config.ts
  2. Add "use cache" to static pages first: landing page, blog, docs, pricing
  3. Set up cacheLife profiles for content that needs time-based revalidation
  4. Use cacheTag and revalidateTag for on-demand invalidation in Server Actions
  5. Test in production mode with next build && next start, because caching behavior differs from next dev

The new model is more predictable once you understand it. You control exactly what gets cached, for how long, and how it gets invalidated. No more hunting through docs to figure out why your page is serving stale data (or why it isn't caching at all).

For a deeper look at how caching interacts with SEO and rendering strategies, see the Next.js SEO guide for SaaS. And if you're caching pages that handle Supabase authentication, pay close attention to the cookies() restriction. Caching an auth-dependent page with the wrong directive will either throw a build error or, worse, serve another user's session.

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. Directives: use cache— nextjs.org
  2. Next.js 16— nextjs.org
  3. Getting Started: Caching— nextjs.org
  4. Functions: cacheLife— nextjs.org

Related Posts

Mar 23, 2026·Tutorial

Rate Limit Next.js Server Actions Before Abuse

Server Actions are public HTTP endpoints anyone can call. Here's how to add rate limiting to login, checkout, and contact forms.

Mar 20, 2026·Tutorial

Next.js proxy.ts Auth: Protect Routes with Supabase

Next.js 16 renamed middleware.ts to proxy.ts. Here's how to migrate your Supabase route protection and understand what actually changed.

Mar 1, 2026·Tutorial

Send Emails in Next.js with React Email + Resend

Stop writing HTML strings for emails. Learn how to build type-safe, component-based email workflows in Next.js using Resend and React Email.