Skip to main content

Command Palette

Search for a command to run...

Understanding Next.js Caching: How I Optimized App Using ISR, use cache & revalidateTag()

Updated
β€’4 min readβ€’View as Markdown
Understanding Next.js Caching: How I Optimized App Using ISR, use cache & revalidateTag()
A
πŸ€– Curious AI enthusiast and Software Engineer with 7+ years of experience. πŸ› οΈ I enjoy getting my hands dirty with new technologies β€” experimenting, building side projects, and learning by doing. πŸš€ Currently exploring how AI can enhance real-world web applications.

While building My Project, one of the biggest challenges wasn't building the UIβ€”it was deciding how to cache data correctly.

I wanted my application to:

  • ⚑ Feel instant while navigating

  • πŸ” Remain SEO-friendly

  • πŸ“‰ Reduce database queries on Neon

  • πŸ”„ Show updated data immediately after an admin edits something

  • πŸ“ˆ Scale to thousands of users

After reading a lot of documentation and experimenting with different approaches, I finally settled on a hybrid caching strategy using ISR + Data Cache.


The Different Caching Layers

When I first started, I assumed there was only one cache in Next.js.

Turns out there are multiple layers.

Browser
    β”‚
    β–Ό
Route Cache (ISR)
    β”‚
    β–Ό
Data Cache (use cache)
    β”‚
    β–Ό
Neon PostgreSQL

Each layer has its own responsibility.

Layer Purpose
ISR (Incremental Static Regeneration) Caches the generated HTML/RSC payload
use cache Caches expensive database queries
Router Cache Makes client-side navigation feel instant
Browser Cache Stores static assets like JS, CSS and images

My Final Architecture

                 πŸ‘€ User
                    β”‚
                    β–Ό
          Next.js Route (ISR)
                    β”‚
          HTML Already Cached?
           β”‚               β”‚
         Yes              No
           β”‚               β–Ό
           β”‚       Server Component
           β”‚               β”‚
           β”‚               β–Ό
           β”‚        use cache()
           β”‚               β”‚
           β”‚       Cache Hit?
           β”‚         β”‚      β”‚
           β”‚       Yes      No
           β”‚         β”‚       β–Ό
           β”‚         β”‚  Neon PostgreSQL
           β”‚         β”‚       β”‚
           β”‚         β””β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
           β–Ό
      Return HTML

This means that in most cases:

  • The page never reaches the database.

  • If ISR misses, the Data Cache still protects the database.

  • Even if both caches miss, only then does Neon receive a query.


My Cache Configuration

For expensive database queries I use:

'use cache'

cacheLife('24h')
cacheTag(`restaurant:${id}`)
cacheTag('restaurants')

I intentionally chose a 24-hour TTL.

Why?

Because TTL is only a fallback.

The real freshness comes from tag invalidation.


How Updates Work

Whenever an admin edits a restaurant:

await prisma.restaurant.update(...)

revalidateTag(`restaurant:${id}`)
revalidateTag('restaurants')

The cache is immediately invalidated.

The next request automatically fetches fresh data and recreates the cache.

Admin Updates Restaurant
          β”‚
          β–Ό
Database Updated
          β”‚
          β–Ό
revalidateTag()
          β”‚
          β–Ό
Cache Removed
          β”‚
          β–Ό
Next Visitor
          β”‚
          β–Ό
Fresh Database Query
          β”‚
          β–Ό
New Cache Created

Because of this workflow, I don't need a short cache TTL like 5 or 10 minutes.


Why I Still Use ISR

Initially, I thought I should remove ISR completely.

After experimenting, I decided to keep it.

ISR helps by caching the rendered HTML, meaning many visitors can receive a ready-to-serve page without triggering a server render.

Even if ISR expires, the page regeneration usually doesn't hit the database because the Data Cache is still valid.

So the two caches work together rather than replacing each other.


What About Multiple Servers?

One interesting thing I learned is that revalidateTag() only invalidates the cache on the server where it's executed.

Imagine three application servers:

              Load Balancer
                     β”‚
      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
      β–Ό              β–Ό              β–Ό
   Server A       Server B       Server C

If the update happens on Server A:

Server A βœ… Fresh

Server B ❌ Old Cache

Server C ❌ Old Cache

To synchronize cache invalidation across every server, a shared system like Redis Pub/Sub or another distributed cache becomes useful.

That's something I plan to add as the application scales.


Why I Didn't Use SWR Everywhere

SWR is fantastic for:

  • Notifications

  • Dashboards

  • User profiles

  • Live data

  • Reviews

However, restaurant information changes relatively infrequently.

Using Server Components + ISR + Data Cache keeps the architecture simple while maintaining excellent SEO and minimizing unnecessary client-side requests.


My Current Stack

  • ⚑ Next.js App Router

  • βš›οΈ React Server Components (RSC)

  • πŸ—„οΈ ISR (Incremental Static Regeneration)

  • πŸ’Ύ use cache

  • 🏷️ cacheTag()

  • πŸ”„ revalidateTag()

  • 🐘 Neon PostgreSQL

  • ☁️ Netlify

  • πŸ“¦ Cloudflare R2 + CDN

  • πŸ”œ Redis (for multi-server cache synchronization)


Final Thoughts

One thing this journey taught me is that not every cache solves the same problem.

  • ISR makes page delivery faster.

  • use cache protects the database.

  • revalidateTag() keeps data fresh without waiting for TTLs.

  • Redis becomes valuable once multiple servers need to share cache invalidation.

Instead of trying to cache everything, understanding which layer should cache what made the biggest difference in both performance and simplicity.