Tarek.
← All posts

A Pragmatic Guide to Caching in Next.js

· 3 min read · Tarek

Caching is the feature everyone loves until it serves something stale. If you've ever shipped a Next.js app and wondered why a page didn't update after deploying, this post is for you.

The trick to staying sane is realizing that "caching in Next.js" is actually four different caches, each with a different job. Once you can name them, debugging becomes straightforward.

The four layers

LayerWhat it cachesDefault lifetime
Request memoizationDuplicate fetches within one render passOne request
Data cacheFetch results across requests and deploysUntil revalidated
Full route cacheRendered routes (HTML + RSC payload)Until invalidated
Router cacheVisited pages on the clientSession / 30s–5min

Let's walk through what that means in practice.

Start from the data

Most staleness bugs start one layer up: someone changed the data, but the route was still serving an old prerender. Before reaching for no-store everywhere, ask one question: how fresh must this data be?

For content that changes on deploy — like blog posts or docs read from files at build time — no configuration is needed. Pages built with static generation are perfect:

app/blog/[slug]/page.tsx
export const dynamicParams = false;
 
export async function generateStaticParams() {
  return getAllPosts().map((post) => ({ slug: post.slug }));
}
 
export default async function Page({ params }: PageProps<"/blog/[slug]">) {
  const { slug } = await params;
  // Read from the filesystem — resolved entirely at build time.
}

For data that changes more often than you deploy, reach for time-based revalidation:

const res = await fetch("https://api.example.com/stats", {
  next: { revalidate: 3600 }, // refresh at most hourly
});

Rule of thumb: pick the slowest freshness your product will tolerate. Every step faster costs you build output, CDN hits, or money.

When the route is the problem

Sometimes the data is fresh but the page isn't. That's the full route cache. Two escape hatches cover almost every case:

  1. Dynamic rendering — reading cookies, headers or search params opts a route out of prerendering.
  2. Revalidating after mutations — when data changes through a Server Action, call revalidateTag so the next request rebuilds the affected pages.
app/actions.ts
"use server";
 
import { revalidateTag } from "next/cache";
 
export async function publishPost(postId: string) {
  await db.post.publish(postId);
  revalidateTag("posts", "max");
}

What I stopped worrying about

  • Request memoization just works; deduplicate freely within a render.
  • Router cache bugginess from early versions is largely historical now.
  • Perfect cache invalidation for user-specific dashboards? Just render dynamically. Static is a tool, not an identity.

Debugging checklist

When something looks stale, work down this list:

  1. Is it stale for everyone or just me? (Incognito + another device)
  2. Did the deploy actually rebuild the page? Check the build output.
  3. Is the fetch cached longer than expected? Look for revalidate.
  4. Am I reading request-time APIs accidentally? That forces dynamic rendering.

Ninety percent of issues die at step two. The remaining ten percent are usually a Cache-Control header from an upstream API that nobody remembered setting.


Caching rewards people who can articulate what they're caching and why. Name the layer, choose its lifetime deliberately, and move on to building things.