Back to Blog

Next.js App Router Caching in 2026: fetch Cache, revalidatePath, and Full-Route Cache

Master Next.js App Router caching in 2026: fetch cache semantics, revalidatePath vs revalidateTag, unstable_cache for non-fetch data, full-route cache, and cache invalidation strategies.

Viprasol Tech Team
January 22, 2027
14 min read

Next.js App Router Caching in 2026: fetch Cache, revalidatePath, and Full-Route Cache

Next.js App Router has four distinct caching layers, and confusing them is one of the most common sources of stale data bugs and unexpected re-fetches. Understanding each layerβ€”what it caches, when it's invalidated, and how to control itβ€”is essential for building fast, correct Next.js applications.

This post works through all four layers with concrete examples: the Request Memoization cache (per-render), the Data Cache (persistent, fetch-based), the Full Route Cache (static HTML), and the Router Cache (client-side). For each, we cover the default behavior, how to opt out, and how to invalidate on demand.


The Four Caching Layers

1. Request Memoization   β†’ Per-render, in-memory, automatic
2. Data Cache            β†’ Persistent, fetch()-based, configurable TTL
3. Full Route Cache      β†’ Static HTML at build time, revalidated on demand
4. Router Cache          β†’ Client-side, session duration, automatic prefetch

They compose, and you need to understand all four to reason about when your UI updates.


Layer 1: Request Memoization

React's cache() function deduplicates identical function calls within a single render tree. If ten Server Components on the same page call getUser(userId), it only executes once.

// lib/data/users.ts
import { cache } from "react";
import { db } from "@/lib/db";

// cache() memoizes per request β€” scoped to one render tree
export const getUser = cache(async (userId: string) => {
  console.log(`DB query: getUser(${userId})`); // Logs only once even if called 10 times
  return db.user.findUnique({
    where: { id: userId },
    select: { id: true, name: true, email: true, role: true },
  });
});

export const getTeamMembers = cache(async (teamId: string) => {
  return db.teamMember.findMany({
    where: { teamId },
    include: { user: { select: { id: true, name: true, avatarUrl: true } } },
  });
});

Key behavior: Memoization is per-request only. A new request = new memoization scope. This is not persistent caching β€” it's deduplication within a render.

// Multiple components can call getUser() β€” DB only queried once per request
// app/dashboard/page.tsx
export default async function DashboardPage() {
  const user = await getUser(userId);         // DB query #1

  return (
    <div>
      <Header userId={userId} />              {/* calls getUser() β†’ memoized */}
      <Sidebar userId={userId} />             {/* calls getUser() β†’ memoized */}
      <MainContent userId={userId} />         {/* calls getUser() β†’ memoized */}
    </div>
  );
}
// Total DB queries for getUser: 1 (not 4)

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different β€” senior engineers only, direct Slack access, and a 5.0β˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript β€” production-grade stack
  • Fixed-price contracts β€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Layer 2: Data Cache (fetch with next options)

When you use fetch() in Server Components, Next.js extends it with caching options. This cache persists across requests and server restarts.

// app/dashboard/page.tsx

// ── Default: cached indefinitely (equivalent to cache: 'force-cache')
const posts = await fetch("https://cms.example.com/api/posts");

// ── Revalidate every 60 seconds (ISR-style)
const posts = await fetch("https://cms.example.com/api/posts", {
  next: { revalidate: 60 },
});

// ── Never cache β€” always fresh
const posts = await fetch("https://cms.example.com/api/posts", {
  cache: "no-store",
});

// ── Cache with tags β€” allows targeted invalidation
const post = await fetch(`https://cms.example.com/api/posts/${slug}`, {
  next: {
    revalidate: 3600,          // Revalidate hourly as fallback
    tags: [`post:${slug}`, "posts"],  // Tag for targeted invalidation
  },
});

Tag-based invalidation from Server Actions:

// app/actions/cms.ts
"use server";

import { revalidateTag } from "next/cache";

export async function publishPost(postId: string) {
  await db.post.update({
    where: { id: postId },
    data: { status: "published", publishedAt: new Date() },
  });

  // Invalidate all fetch() calls tagged with these tags
  revalidateTag(`post:${postId}`);  // Invalidate specific post
  revalidateTag("posts");            // Invalidate post listing pages
}

export async function updateSiteSettings(settings: Record<string, unknown>) {
  await db.siteSettings.update({ where: { id: "global" }, data: settings });
  revalidateTag("site-settings");
}

Layer 3: Non-fetch Data with unstable_cache

fetch() caching only works for HTTP requests. For direct database queries or external SDKs, use unstable_cache:

// lib/data/cached-queries.ts
import { unstable_cache } from "next/cache";
import { db } from "@/lib/db";

// Cache a Prisma query with TTL and tags
export const getCachedPublishedPosts = unstable_cache(
  async (limit: number = 10) => {
    return db.post.findMany({
      where: { status: "published" },
      orderBy: { publishedAt: "desc" },
      take: limit,
      select: { id: true, title: true, slug: true, excerpt: true, publishedAt: true },
    });
  },
  ["published-posts"],    // Cache key parts
  {
    revalidate: 300,       // 5 minutes
    tags: ["posts"],       // Invalidate with revalidateTag("posts")
  }
);

// ⚠️ CRITICAL: scope by user ID for user-specific data
// Without scoping, all users see the same cached result
export const getCachedUserDashboard = unstable_cache(
  async (userId: string) => {
    return db.dashboard.findUnique({ where: { userId } });
  },
  ["user-dashboard"],
  {
    revalidate: 60,
    tags: ["dashboard"],
    // The cache key includes the function arguments automatically
    // But add userId explicitly to the key parts for safety:
  }
);

// Correct pattern: include dynamic parts in key
export function getUserDashboard(userId: string) {
  return unstable_cache(
    async () => db.dashboard.findUnique({ where: { userId } }),
    [`user-dashboard:${userId}`],   // User-scoped key
    {
      revalidate: 60,
      tags: [`dashboard:${userId}`],
    }
  )();
}

Invalidating unstable_cache entries:

// app/actions/dashboard.ts
"use server";

import { revalidateTag } from "next/cache";

export async function updateDashboardLayout(userId: string, layout: any) {
  await db.dashboard.update({ where: { userId }, data: { layout } });

  // Invalidate this user's cached dashboard
  revalidateTag(`dashboard:${userId}`);
}

πŸš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Layer 4: Full Route Cache

Next.js statically renders routes at build time when possible and serves pre-rendered HTML. Dynamic routes (using cookies(), headers(), or searchParams) opt out automatically.

// app/blog/[slug]/page.tsx

// Static generation β€” rendered at build time for all slugs
export async function generateStaticParams() {
  const posts = await db.post.findMany({
    where: { status: "published" },
    select: { slug: true },
  });
  return posts.map((p) => ({ slug: p.slug }));
}

// Revalidate the full-route cache every hour
export const revalidate = 3600;

export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getCachedPost(slug);  // Data cache hit
  // ...
}

On-demand full-route revalidation:

// app/api/revalidate/route.ts
// Called by your CMS webhook when content changes
import { NextRequest, NextResponse } from "next/server";
import { revalidatePath, revalidateTag } from "next/cache";

export async function POST(req: NextRequest) {
  const secret = req.headers.get("x-revalidate-secret");
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { slug, type } = await req.json();

  if (type === "post") {
    // Invalidate the specific post page
    revalidatePath(`/blog/${slug}`);

    // Invalidate the blog listing page
    revalidatePath("/blog");

    // Invalidate data cache for this post
    revalidateTag(`post:${slug}`);
    revalidateTag("posts");
  }

  if (type === "site-settings") {
    // Revalidate every page (expensive β€” use sparingly)
    revalidatePath("/", "layout");
  }

  return NextResponse.json({ revalidated: true, timestamp: Date.now() });
}

revalidatePath vs revalidateTag

// revalidatePath β€” invalidates the full-route cache for a URL
revalidatePath("/blog");           // Specific path
revalidatePath("/blog/[slug]", "page");  // All dynamic segments
revalidatePath("/", "layout");    // All pages using this layout

// revalidateTag β€” invalidates the Data Cache for tagged fetch/unstable_cache calls
revalidateTag("posts");           // All cached queries tagged "posts"
revalidateTag(`post:${slug}`);   // Just this post's cached data

// Key difference:
// revalidatePath β†’ next request re-renders the page Server Component
// revalidateTag  β†’ next fetch() or unstable_cache call re-queries the data source

// Typical pattern: invalidate both
export async function publishPost(slug: string) {
  await db.post.update({ where: { slug }, data: { status: "published" } });

  revalidateTag(`post:${slug}`);   // Bust Data Cache
  revalidatePath(`/blog/${slug}`); // Bust Full-Route Cache
  revalidatePath("/blog");         // Bust listing page Full-Route Cache
}

Router Cache (Client-Side)

The Router Cache stores RSC payloads on the client for previously visited routes and prefetched links. It's automatic and session-scoped (cleared on browser refresh).

// Force a client-side cache refresh after a Server Action
"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

export async function createProject(formData: FormData) {
  const project = await db.project.create({
    data: { name: formData.get("name") as string, ... },
  });

  // revalidatePath also clears the Router Cache for this path
  revalidatePath("/projects");

  // Redirect to the new project
  redirect(`/projects/${project.id}`);
}
// Client component: force router refresh programmatically
"use client";

import { useRouter } from "next/navigation";

export function RefreshButton() {
  const router = useRouter();

  return (
    <button
      onClick={() => router.refresh()}  // Re-fetches RSC payload, clears router cache
      className="text-sm text-blue-600 hover:underline"
    >
      Refresh
    </button>
  );
}

Caching Decision Tree

Is data user-specific?
  YES β†’ Use cache() (request memoization only) + cache: 'no-store'
  NO  β†’ Is it a fetch() call?
          YES β†’ Use next: { revalidate: N, tags: [...] }
          NO  β†’ Use unstable_cache with user-scoped key parts

Does the page need to be statically generated?
  YES β†’ Use generateStaticParams() + export const revalidate = N
  NO  β†’ Add cookies()/headers() call or use dynamic = 'force-dynamic'

How to invalidate?
  On mutation β†’ revalidateTag() for data, revalidatePath() for pages
  On schedule β†’ next: { revalidate: N } sets background revalidation
  On webhook  β†’ POST to /api/revalidate β†’ call revalidatePath/Tag

Common Mistakes

// ❌ Caching user-specific data without scoping
const userData = unstable_cache(
  async (userId: string) => db.user.findUnique({ where: { id: userId } }),
  ["user-data"]  // Same key for all users β€” data leak risk!
)

// βœ… Scope cache key to user
const userData = unstable_cache(
  async (userId: string) => db.user.findUnique({ where: { id: userId } }),
  [`user-data:${userId}`]  // User-scoped
)

// ❌ Using no-store in a layout β€” makes every child dynamic
// app/layout.tsx
const config = await fetch("/api/config", { cache: "no-store" }); // Opts out entire layout

// βœ… Cache config with a long TTL
const config = await fetch("/api/config", { next: { revalidate: 3600, tags: ["config"] } });

// ❌ Calling revalidateTag in a Server Component (no effect)
// app/page.tsx β€” Server Component
revalidateTag("posts"); // This is a no-op β€” only works in Server Actions or route handlers

// βœ… Call revalidateTag in a Server Action or API route
// app/actions/posts.ts
"use server";
export async function refreshPosts() {
  revalidateTag("posts"); // This works
}

Cost and Timeline

TaskTimelineCost (USD)
Caching audit of existing Next.js app1–2 days$800–$1,600
Data cache strategy + tag design1 day$600–$1,000
Static generation + ISR setup1 day$600–$1,000
Webhook-based revalidation endpoint0.5 day$300–$500
Full caching implementation1 week$4,000–$7,000

See Also


Working With Viprasol

We architect Next.js caching strategies for production applications β€” from ISR-driven content sites through complex SaaS dashboards with per-user data. Our team has optimized caching for Next.js applications serving millions of page views monthly.

What we deliver:

  • Caching layer audit: identify stale data bugs and unnecessary re-fetches
  • Tag-based cache invalidation wired to your CMS or data mutations
  • Static generation + ISR for content-heavy routes
  • Per-user cache scoping to prevent data leaks
  • Performance measurement before and after

Explore our web development services or contact us to optimize your Next.js caching strategy.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms β€” we build it all with Next.js and React.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse β‰₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β€” from architecture through deployment.