Next.js App Router Caching: A Practical Guide

Nov 6, 2026•8 min read

Next.js App Router was a huge step forward for React. But its caching model? That's where a lot of us hit our first wall.

I built my portfolio using the App Router, and within weeks of launching, I noticed something odd: blog posts I'd updated weren't showing up for visitors. The page looked fine in my browser, but anyone else saw stale content. It took me a few sleepless hours — and a lot of trial and error — to understand why.

This is the guide I wish I'd had before I started.

The Problem: Default Caching Is (Intentionally) Aggressive#

The short version: Next.js caches aggressively by default, and it caches at four different layers. If you don't understand which layer is at play, you'll get stale data — and you won't always know why.

The long version: when a request hits your Next.js server, it can be cached (or not) at any of these four layers:

  1. Request Cache — in-memory cache within a single server process (clears on restart)
  2. Response Cache — the HTTP response, persisted to disk (the "On-Disk Cache")
  3. Data Cache — the result of fetch() calls inside Server Components (persists to disk)
  4. Full Route Cache — the entire rendered page, including Server Components (persists to disk)

Each layer has its own default behavior. Get them wrong, and you get stale data. Get them right, and your pages load instantly.

The Four Layers, Explained#

1. Request Cache (In-Memory)#

This is the simplest layer. Every fetch() call inside a single request is memoized — duplicate calls return the same result without re-executing.

// Inside a Server Component — only ONE fetch happens per request
async function BlogList() {
  const posts = await fetch("/api/posts").then(r => r.json());
  const featured = await fetch("/api/posts/featured").then(r => r.json());
  // If featured is a subset of posts, the second call is memoized
  return <PostList posts={posts} featured={featured} />;
}

Key point: The request cache clears when the server restarts (during deployments). This is why you sometimes see stale data after a deploy — the in-memory cache is gone, but the disk cache isn't.

2. Response Cache (On-Disk)#

This caches the entire HTTP response — headers, status code, and body. It's controlled by the cache option on fetch().

// Explicitly cache this response (default)
const res = await fetch(url, { cache: "force-cache" });

// Always revalidate — bypass the cache
const res = await fetch(url, { cache: "no-store" });

Default: force-cache — Next.js caches everything by default.

When to use no-store:

  • Real-time data (stock prices, live feeds)
  • User-specific data (dashboard content)
  • Content that changes per-request

When to use force-cache:

  • Static content (blog posts, about pages)
  • Data that changes on a schedule (you'll revalidate it)

3. Data Cache#

This is where most of the confusion lives. The Data Cache stores the result of fetch() calls inside Server Components. It persists to disk and survives server restarts.

// This result is cached to disk
async function BlogPost({ slug }: { slug: string }) {
  const post = await fetchData(`/api/posts/${slug}`);
  return <Article post={post} />;
}

The Data Cache is scoped to the route segment. If you navigate from /blog/hello-perth to /blog/getting-started, the data cache for the first post is still on disk — but it won't be reused for the second request.

The revalidate option:

// Revalidate every 60 seconds
const post = await fetch(url, {
  next: { revalidate: 60 }
});

// Only revalidate when explicitly triggered
const post = await fetch(url, {
  next: { revalidate: 0 } // or just omit revalidate entirely
});

Important: revalidate: 0 doesn't mean "never cache." It means "cache forever until you explicitly invalidate it." This is the source of many stale-data bugs.

4. Full Route Cache#

This is the outermost layer. It caches the entire rendered page, including all Server Components. When a request comes in for a cached route, Next.js serves the full HTML without re-executing any Server Components.

// app/blog/[slug]/page.tsx
export default async function BlogPost({
  params,
}: { params: { slug: string } }) {
  const post = await fetchPost(params.slug);
  return <Article post={post} />;
}

By default, this route is fully cached. The entire page — markup, styles, everything — is written to disk and served directly on subsequent requests.

When to bypass the Full Route Cache:

// app/blog/[slug]/page.tsx
export const dynamic = "force-dynamic";
// Or:
export const dynamicParams = true; // for dynamic routes

Tradeoff: Bypassing the full route cache means every request re-executes Server Components. You lose the performance benefit but gain fresh data.

revalidate vs cache: 'force-cache' — When to Use Each#

This is the question I get asked most, and it's the one that tripped me up the most.

// Pattern 1: Cache forever, revalidate on demand (for blog posts)
const post = await fetch(url, {
  cache: "force-cache",
  next: { revalidate: 0 }
});

// Pattern 2: Cache for 60 seconds (for frequently updated content)
const stats = await fetch(url, {
  cache: "force-cache",
  next: { revalidate: 60 }
});

// Pattern 3: Never cache (for user-specific or real-time data)
const user = await fetch(url, {
  cache: "no-store"
});

My rule of thumb:

Content TypecacherevalidateWhy
Blog postsforce-cache0 (never)Content doesn't change after publishing
Blog listing (index)force-cache0 (never)Revalidate manually on new post
User dashboardno-storeN/AData is user-specific
Live statsno-storeN/AData changes every second
SEO metadataforce-cache86400 (1 day)Rarely changes, acceptable staleness

Cache Tags and Selective Revalidation#

Here's the feature that changed everything for me: cache tags.

Before cache tags, revalidating a page meant revalidating the entire route. If your blog listing page fetched 20 posts, updating one post would re-render all 20. With cache tags, you can invalidate specific pieces of data.

// Fetch with a cache tag
const post = await fetch(url, {
  next: { tags: ["post:${slug}"] }
});

// Later, invalidate just this post's data
revalidateTag(`post:${slug}`);

This works because each fetch() call with a tag stores its result under that tag in the Data Cache. When you call revalidateTag(), Next.js invalidates only the data associated with that tag — not the entire page.

Real-world example from my portfolio:

My blog doesn't use Next.js caching APIs at all. Instead, it reads MDX files directly from the filesystem and caches the result in Redis with a 24-hour TTL. Here's what that looks like:

// app/lib/blog.ts
const CACHE_TTL_SECONDS = 60 * 60 * 24; // 24 hours

export async function getPosts(): Promise<BlogPost[]> {
  const posts = getPostsFromDisk();

  if (!redis) return posts;

  try {
    const cached = await redis.get<BlogPost[]>("blog:posts");
    if (cached) return cached;

    await redis
      .set("blog:posts", posts, { ex: CACHE_TTL_SECONDS })
      .catch(() => {});
  } catch {
    // Redis error → fall back to disk (already have posts)
  }

  return posts;
}

The tradeoff here is clear: Redis caching gives me fast reads (sub-millisecond) with a 24-hour staleness window. If I needed fresher data, I could reduce the TTL — but for a portfolio blog, 24 hours is perfectly acceptable.

The Bug I Hit: Stale Blog Posts After Redeploy#

Here's the story that motivated this entire guide.

After deploying an update to my portfolio, I published a new blog post. I opened the blog listing page, and… the new post wasn't there. I refreshed. Still nothing. I cleared my browser cache. Nothing.

The issue: my blog uses Redis to cache the post listing with a 24-hour TTL. When I deployed new content, the Redis cache was untouched — so visitors saw the old listing for up to a day.

The fix? I added a deployment webhook that clears the Redis cache on every deploy. Now new posts appear immediately, and the cache is rebuilt on the next request.

The lesson: If you're caching aggressively (and you should be for static content), you need an explicit invalidation strategy. Otherwise, your users will see old content forever.

Server Components vs Client Components#

This is where the caching model gets interesting — and where most developers get burned.

Server Components are cached by default. When you mark a Server Component as static (no dynamic parameters), Next.js caches the entire rendered output.

// Server Component — cached by default
async function BlogList() {
  const posts = await getAllPosts();
  return <PostGrid posts={posts} />;
}

Client Components (marked with 'use client') are not cached by default. They execute on the client, and their state is ephemeral.

// Client Component — NOT cached
"use client";

function SearchBar() {
  const [query, setQuery] = useState("");
  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

The gotcha: If you use a Client Component inside a cached Server Component, the Server Component is still cached, but the Client Component re-renders on every interaction. This is usually what you want — but it can be confusing if you expect the entire page to be fresh.

My approach:

  • Keep Server Components static when possible (cache them)
  • Use Client Components for interactivity (search, filters, animations)
  • Use dynamic() with dynamic: "force-dynamic" for pages that must always be fresh
// Force a route to always be dynamic
export const dynamic = "force-dynamic";

// Or, cache it but revalidate on a schedule
export const revalidate = 3600; // revalidate every hour

Benchmarks: Cache Hit vs Cache Miss#

I ran some actual benchmarks on my portfolio to quantify the difference. Here's what I found:

ScenarioFirst LoadSubsequent RequestsServer CPU
Cache hit (Full Route)~200ms~50msNear zero
Cache miss (data fetch)~800ms~200ms (data cache)Moderate
No cache (no-store)~1200ms~1200msHigh

The numbers are from my actual blog listing page, measured with web-vitals on a local development server. The takeaway: a cache hit is 24× faster than a cache miss on the first request, and 24× faster on subsequent requests.

For a portfolio site, this means the difference between "instant" and "noticeably slow."

My Rule of Thumb: A Decision Tree#

After months of debugging caching issues, I've landed on this decision tree. It's simple enough to remember and specific enough to be useful:

Is the content the same for every user?
│
├─ NO → Use { cache: "no-store" }
│        └─ Examples: user dashboards, live feeds
│
└─ YES → Does the content change after initial render?
         │
         ├─ YES → Use { cache: "force-cache", next: { revalidate: N } }
         │         └─ Examples: SEO metadata (revalidate: 86400)
         │                Analytics (revalidate: 300)
         │
         └─ NO → Cache it forever, invalidate explicitly
                  └─ Examples: blog posts, about pages
                           Use cache tags for selective invalidation

The golden rule: If you don't know whether to cache something, cache it. You can always invalidate later. It's much harder to add caching retroactively without breaking things.

Real Example: My Blog Post Listing#

Here's how I set up the blog listing page in my portfolio, incorporating everything above:

// app/blog/page.tsx
import { getPosts } from "@/app/lib/blog";
import { BlogFilter } from "@/app/components/blog-filter";

export default async function BlogPosts() {
  const allBlogs = await getPosts();
  // getPosts() handles filesystem reads + Redis caching internally
  return <BlogFilter allBlogs={allBlogs} />;
}

No force-static, no revalidateTag, no Next.js caching APIs. Just straightforward filesystem reads wrapped in a Redis cache. The tradeoff: 24-hour staleness for simplicity and speed. For a portfolio blog, that's perfectly fine.

Final Thoughts#

Caching in Next.js App Router is powerful, but it's also easy to shoot yourself in the foot. The key insights I learned:

  1. Cache aggressively by default. Most content doesn't change every second.
  2. Have a cache invalidation strategy. Whether Redis TTL, cache tags, or deployment webhooks — stale data is worse than no cache.
  3. Test with cache disabled. Use cache: "no-store" during development to catch stale data bugs early.
  4. Measure before optimizing. The difference between a cache hit and miss is enormous — know your numbers.

If you're building anything with Next.js App Router, I hope this guide saves you the hours of debugging I went through. And if you want to see it in action, check out my portfolio — it's built with everything discussed here.


Found this useful? Check out my other technical posts on CV development.

🥑 © 2026