alirezasaremi.com logo
alirezasaremi.com logo

Alireza Saremi

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

2026-02-01

Next.js

In my previous post, I've introduced new features of Next.js 16. I believe Cache Components is a game changer in terms of user experience and offering some benefits for both developers and end-users.
That's why, I've created this post and included some examples to make this article as a practical guide to Cache Components in Next.js 16.
We'll focus on real patterns you can use in a production app. We're replacing ISR for “mostly static” pages, keeping SSR only where necessary, and utilizing cacheLife and cacheTag to control freshness in a clean and predictable manner.

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. At the end, I'll give you a guideline to use th proper caching system in different situations.

Table of Contents

1. Enable Cache Components (One Time Setup)

Cache Components is an opt-in feature. That means you can simply enable or disable this feature 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)

When it comes to caching complex pages, our mind is going to think about choosing "ISR" or "SSR". Right?
Let's review both models to find a what is important in different situations.

On one hand, we have Incremental Static Regeneration (ISR). Rebuild the page every X seconds from scratch. This was a possible solution in the past, but now it's mainly used for truly dynamic pages that require specific content.

On the other hand, the new cache component approach says: "Cache this expensive work and control how long it stays fresh." By doing so, you can cache only the parts that are most costly - like database queries. This means you can leave the rest of the page dynamic, which is perfect for scenarios where user-specific content is a must.

These explanations help you simplify your caching strategy and focus on what's truly important: balancing performance with user experience.

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

Let’s say a 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 like day, hours, etc. )
  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((response) => response.json())
  return posts
}

Now the 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 is still “dynamic by default”, but the expensive query is cached. This is a better replacement for ISR. Because we avoid full rebuilds and we can invalidate content precisely as well as we have much control on page contents.

4. Second Example: Fresh price and inventory in a product page

Nearly all product pages have 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((response) => response.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((response) => response.json())
}

Then in your product page, merge 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>
  )
}

The result of the above code is users get a fast page because the heavy product data is cached, and they still see fresh pricing because that part stays dynamic.

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

The ISR is very good approach in some cases but there is a big pitfall on some cases. The ISR pages rebuild every X seconds, even if nothing changed.

With the help of Cache Components, we 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. Forth Example: 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. That means, this guarantees when a user reads data from a system, they will see the most up-to-date version of that data.

// 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 above examples implicitly tell us, 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 different data for each user.
  • Pages that depend on cookies or session on the server.
  • Highly dynamic content like realtime notifications.

By the way, we can explicitly force Next.js to render the page on the client-side, despite being marked as SSR:

// 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

When it comes to implementing caching strategies, here are some guidelines to consider:

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

If you follow this, you usually get better performance than ISR, and simpler code than make 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.