alirezasaremi.com logo
alirezasaremi.com logo

Alireza Saremi

Cache Components in Practice: Replacing ISR and SSR the Right Way

2026-02-01

Next.js

This post is a practical guide to Cache Components in Next.js 16. We will focus on real patterns you can use in a production app: replacing ISR for “mostly static” pages, keeping SSR only where you truly need it, and using cacheLife + cacheTag to control freshness in a clean, predictable way.

The big idea is simple: in Next.js 16, caching becomes explicit. Your code is dynamic by default, and you choose what should be cached.

Table of Contents

1. Enable Cache Components (One Time Setup)

Cache Components is an opt-in feature. Enable it once in your next.config.ts. After that, you can use the use cache directive and functions like cacheLife and cacheTag.

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

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

After enabling it, you can cache pages, components, or even individual functions. That means you can build pages that are fast like SSG, but still stay fresh like SSR.

2. The Practical Mental Model (What Replaces ISR vs SSR)

Here is the easiest way to think about it:

  • Old ISR: “Rebuild this page every X seconds.”
  • New Cache Components: “Cache this expensive work, and control how long it stays fresh.”
  • SSR: Still useful, but mostly for truly user-specific pages (like dashboards).

The win is that you can cache only the parts that are expensive (like database queries), while leaving the rest dynamic.

3. Example 1: Blog Home (Replace ISR with Cached Data)

Let’s say your blog home page lists the latest posts from a CMS. With ISR, you might set revalidate = 60 and rebuild the whole route frequently. With Cache Components, you can cache only the data fetching.

Create a server function and cache it:

// app/lib/posts.ts
import { cacheLife, cacheTag } from 'next/cache'

export async function getLatestPosts() {
  'use cache'

  // choose a built-in cache profile (semantic label)
  cacheLife('hours')

  // tag the result so we can invalidate it later
  cacheTag('blog-posts')

  // replace this with your real DB/CMS call
  const posts = await fetch('https://example.com/api/posts').then((r) => r.json())
  return posts
}

Now your blog page can stay simple:

// app/blog/page.tsx
import { getLatestPosts } from '../lib/posts'

export default async function BlogPage() {
  const posts = await getLatestPosts()

  return (
    <main>
      <h1>Blog</h1>
      <ul>
        {posts.map((p) => (
          <li key={p.id}>{p.title}</li>
        ))}
      </ul>
    </main>
  )
}

The page itself is still “dynamic by default”, but your expensive query is cached. This is often a better replacement for ISR because you avoid full rebuilds and you can invalidate content precisely.

4. Example 2: Product Page (Fast Shell + Fresh Price)

Product pages are a classic problem: the product description rarely changes, but the price and inventory can change often.

With Cache Components, you can cache the “stable” data for longer, and keep the “volatile” data fresh.

// app/lib/product.ts
import { cacheLife, cacheTag } from 'next/cache'

export async function getProductDetails(productId: string) {
  'use cache'
  cacheLife('days')          // description and images change rarely
  cacheTag(`product:${productId}`)

  return fetch(`https://example.com/api/products/${productId}`).then((r) => r.json())
}

export async function getLivePrice(productId: string) {
  // keep this dynamic (no 'use cache') for fresh price/inventory
  return fetch(`https://example.com/api/prices/${productId}`, { cache: 'no-store' })
    .then((r) => r.json())
}

Then in your page, you combine them:

// app/products/[id]/page.tsx
import { getProductDetails, getLivePrice } from '../../lib/product'

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await getProductDetails(params.id)
  const price = await getLivePrice(params.id)

  return (
    <main>
      <h1>{product.title}</h1>
      <p>{product.description}</p>
      <p><strong>Price:</strong> {price.amount}</p>
    </main>
  )
}

Result: users get a fast page because the heavy product data is cached, and they still see fresh pricing because that part stays dynamic.

5. Example 3: Webhook or Admin Update (Revalidate by Tag)

One big weakness of ISR was that it often felt “global”: rebuild every X seconds, even if nothing changed.

With Cache Components, you can invalidate exactly what changed by using revalidateTag. For example, if your CMS calls a webhook after a post is published, you can revalidate only blog-posts.

// app/api/webhooks/cms/route.ts
import { revalidateTag } from 'next/cache'

export async function POST(req: Request) {
  // verify signature here in real apps

  // invalidate cached blog data (stale-while-revalidate)
  revalidateTag('blog-posts', 'max')

  return Response.json({ ok: true })
}

After this call, users can still get cached content instantly, while Next.js refreshes the cache in the background for the next visitor.

6. Example 4: Read-Your-Writes After a Mutation

Sometimes, “stale-while-revalidate” is not enough. For example, if a user updates their profile, they expect to immediately see the new data.

In those cases, Next.js provides APIs designed for server actions so you can get “read-your-writes” behavior (the next read shows the new version).

// app/profile/actions.ts
'use server'

import { updateTag } from 'next/cache'
import { saveProfile } from '../lib/db'

export async function updateProfileAction(formData: FormData) {
  await saveProfile({
    name: String(formData.get('name')),
  })

  // guarantee the next read sees the latest profile data
  updateTag('profile')
}

The lesson: use revalidateTag for “eventual freshness”, but use a “read-your-writes” API when the user needs immediate correctness.

7. When SSR Is Still the Right Choice

Cache Components reduces the need for SSR, but SSR is still the best option for pages that are truly user-specific and must be correct on every request:

  • Dashboards with private data (per user).
  • Pages that depend on cookies/session on the server.
  • Highly dynamic content (like real-time notifications).

In Next.js, you can explicitly force a route to be dynamic:

// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'

export default async function Dashboard() {
  const data = await getUserDashboardData()
  return <div>Welcome back!</div>
}

8. A Simple Checklist for Real Projects

Use this checklist when you build a new page:

  • Does the data change rarely? Cache it with use cache + cacheLife.
  • Can updates be delayed? Use cacheTag + revalidateTag (SWR behavior).
  • Does the user need immediate correctness? Use a “read-your-writes” approach (server actions).
  • Is the page user-specific? Keep it dynamic (SSR-like) and cache only safe shared fragments.

If you follow this, you usually get better performance than ISR, and simpler code than “everything SSR.”

9. Conclusion

Cache Components gives you a clean middle ground between SSG/ISR and SSR. You choose what to cache, how long it stays fresh, and how it gets invalidated. In real projects, this leads to faster pages, fewer rebuilds, and more predictable behavior.

If you want the simplest starting point: cache your expensive data functions, tag them, and revalidate by tag when your content changes. That single pattern replaces most ISR use cases in a more scalable way.