Skip to main content

Command Palette

Search for a command to run...

Fix Next.js 15 App Router Cache Issues

Learn: Fix Next.js 15 App Router Cache Issues

Updated
5 min readView as Markdown
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Fix Next.js 15 App Router Cache Issues

Problem: Understanding Cache Behavior in Next.js 15

Next.js 15's App Router introduces a sophisticated caching system that can be confusing and lead to unexpected behavior. The framework caches at multiple levels: Request Memoization, Data Cache, and Full Route Cache. When these layers interact incorrectly, you might experience:

  • Stale data persisting across requests
  • Dynamic routes serving cached content
  • User-specific data leaking between sessions
  • Build-time data appearing in production
  • Revalidation not working as expected

Understanding and properly configuring these cache layers is crucial for building reliable applications.


Fix 1: Disable Caching for Dynamic Routes

Problem: Your dynamic routes are serving cached data when they shouldn't be.

Solution: Use dynamic = 'force-dynamic' to opt out of static generation.

// app/user/[id]/page.tsx
export const dynamic = 'force-dynamic';

export default async function UserPage({ params }: { params: { id: string } }) {
  const user = await fetch(`https://api.example.com/users/${params.id}`, {
    cache: 'no-store'
  });

  return <div>{user.name}</div>;
}

Why it works: This tells Next.js to render the page on every request rather than at build time, ensuring fresh data.


Fix 2: Control Data Cache with Fetch Options

Problem: API calls are being cached indefinitely, causing stale data.

Solution: Explicitly set cache behavior on fetch requests.

// app/posts/page.tsx
export default async function PostsPage() {
  // Option 1: No caching
  const posts = await fetch('https://api.example.com/posts', {
    cache: 'no-store'
  });

  // Option 2: Revalidate after 60 seconds
  const featured = await fetch('https://api.example.com/featured', {
    next: { revalidate: 60 }
  });

  // Option 3: Cache indefinitely (default)
  const categories = await fetch('https://api.example.com/categories', {
    next: { revalidate: false }
  });

  return (
    <div>
      <h1>Posts</h1>
      {/* render data */}
    </div>
  );
}

Why it works: Fetch-level cache control gives you granular power over what gets cached and for how long.


Fix 3: Implement On-Demand Revalidation

Problem: You need to invalidate cache when data changes, but ISR (Incremental Static Regeneration) timing doesn't match your needs.

Solution: Use revalidateTag() and revalidatePath() with API routes.

// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const secret = request.nextUrl.searchParams.get('secret');

  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
  }

  const tag = request.nextUrl.searchParams.get('tag');
  const path = request.nextUrl.searchParams.get('path');

  if (tag) {
    revalidateTag(tag);
  }

  if (path) {
    revalidatePath(path);
  }

  return NextResponse.json({ revalidated: true, now: Date.now() });
}
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
    next: { tags: ['blog-post', `blog-${params.slug}`] }
  });

  return <article>{/* render post */}</article>;
}

Why it works: Tags let you group related cache entries and invalidate them together when needed, giving you event-driven cache invalidation.


Fix 4: Handle User-Specific Data Correctly

Problem: User A sees User B's cached data.

Solution: Include user context in cache keys and disable caching for personalized content.

// app/dashboard/page.tsx
import { headers } from 'next/headers';

export const dynamic = 'force-dynamic';

export default async function Dashboard() {
  const headersList = headers();
  const userId = headersList.get('x-user-id');

  const userData = await fetch(
    `https://api.example.com/users/${userId}/dashboard`,
    {
      cache: 'no-store',
      headers: {
        'Authorization': `Bearer ${process.env.API_TOKEN}`
      }
    }
  );

  return <div>{/* render user-specific content */}</div>;
}

Why it works: force-dynamic ensures the page renders per-request, and no-store prevents any caching of user-specific data.


Fix 5: Prevent Cache Leaks in Middleware

Problem: Middleware is caching responses that shouldn't be cached.

Solution: Set proper cache headers in middleware.

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // Prevent caching for authenticated routes
  if (request.nextUrl.pathname.startsWith('/dashboard')) {
    response.headers.set('Cache-Control', 'private, no-cache, no-store, must-revalidate');
  }

  // Allow caching for public content
  if (request.nextUrl.pathname.startsWith('/blog')) {
    response.headers.set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
  }

  return response;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
};

Why it works: Explicit cache headers in middleware prevent unintended caching at the HTTP level.


Fix 6: Debug Cache Issues with Logging

Problem: You don't know what's being cached and why.

Solution: Add comprehensive logging to track cache behavior.

// lib/cache-debug.ts
export async function fetchWithDebug(
  url: string,
  options?: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } }
) {
  const cacheConfig = options?.next;

  console.log(`[CACHE] Fetching: ${url}`, {
    revalidate: cacheConfig?.revalidate,
    tags: cacheConfig?.tags,
    timestamp: new Date().toISOString()
  });

  const response = await fetch(url, options);

  console.log(`[CACHE] Response status: ${response.status}`, {
    url,
    headers: Object.fromEntries(response.headers.entries())
  });

  return response;
}

// Usage in your pages
export default async function Page() {
  const data = await fetchWithDebug('https://api.example.com/data', {
    next: { revalidate: 60, tags: ['data'] }
  });

  return <div>{/* render */}</div>;
}

Why it works: Logging helps you understand cache behavior in development and production.


Tips for Cache Management

1. Use Consistent Revalidation Strategies

Define revalidation patterns at the project level. Create a constants file:

// lib/cache-config.ts
export const CACHE_CONFIG = {
  STATIC: { revalidate: false },
  LONG: { revalidate: 86400 }, // 24 hours
  MEDIUM: { revalidate: 3600 }, // 1 hour
  SHORT: { revalidate: 60 }, // 1 minute
  DYNAMIC: 'force-dynamic'
};

2. Monitor Cache Hit Rates

Track cache effectiveness in production:

// lib/analytics.ts
export function logCacheHit(key: string, hit: boolean) {
  console.log(`Cache ${hit ? 'HIT' : 'MISS'}: ${key}`);
  // Send to analytics service
}

3. Test Cache Behavior Locally

Use next build && next start to test production caching locally, not just next dev.

4. Document Cache Decisions

Add comments explaining why specific cache strategies are used:

// This endpoint changes frequently based on user interactions
// so we disable caching entirely
export const dynamic = 'force-dynamic';

5. Use Segment-Level Configuration

Apply cache settings at the segment level for consistency:

// app/api/layout.tsx
export const dynamic = 'force-dynamic';

6. Combine Multiple Strategies

Use tags + revalidation together for maximum control:

const data = await fetch(url, {
  next: {
    revalidate: 3600,
    tags: ['category', `category-${id}`]
  }
});

Conclusion

Next.js 15's caching system is powerful but requires intentional configuration. Start by identifying which routes need dynamic content, apply appropriate cache controls at the fetch level, and use revalidation tags for event-driven updates. Always test cache behavior in production mode and monitor for stale data issues. With these fixes and tips, you'll build reliable, performant applications that serve fresh data when needed.