Skip to main content

Next.js 15 Caching Changes: Fixing Uncached GET Requests and 'no-store' Defaults

 If you recently migrated a production application from Next.js 14 to Next.js 15, you likely noticed a disturbing trend in your observability tools: a massive spike in upstream API requests.

In Next.js 14, the framework was aggressively opinionated about caching. It attempted to cache everything by default, leading to the infamous "why is my data stale?" problem. Next.js 15 inverts this model. By default, fetch requests, GET Route Handlers, and client-side navigations are now uncached.

While this solves the stale data confusion, it introduces a new risk: unintentional performance degradation and rate-limiting issues because your application is suddenly re-fetching data on every single render.

Here is the root cause of the shift and the precise patterns you need to implement to regain control over your cache strategy.

The Root Cause: From "Force-Cache" to "No-Store"

In Next.js 14, the extended fetch API defaulted to cache: 'force-cache'. If you didn't explicitly opt out, Next.js stored the response in the Data Cache indefinitely until a revalidation event occurred.

In Next.js 15, the heuristic has changed to align closer to standard web standards and reduce developer friction regarding stale data.

  1. Fetch Requests: fetch requests in Server Components now default to no-store (uncached) if you do not provide caching options.
  2. Route Handlers: GET handlers effectively default to dynamic behavior unless specific static configuration options are exported.
  3. Client Router Cache: The client-side router cache now has a default staleTime of 0 for page segments, meaning navigation usually triggers a server request rather than reusing client-side state.

If your v14 code relied on implicit defaults to protect your upstream database or CMS, that protection is gone.

The Fix: Explicit Caching Patterns

To fix performance regressions, you must move from implicit caching to explicit caching. Below are the three specific implementations required for Next.js 15.

1. Caching fetch in Server Components

You can no longer rely on fetch('...') being cached. You must explicitly set the cache option or the next.revalidate option.

The Anti-Pattern (v15 Uncached):

// app/page.tsx
export default async function Page() {
  // ⚠️ In v15, this runs on every request!
  const res = await fetch('https://api.example.com/products');
  const data = await res.json();
  
  return <ProductList data={data} />;
}

The Solution (Time-Based Revalidation): If you want the v14 behavior (ISR) where data is cached for a set period, apply revalidate.

// app/products/page.tsx
import { ProductList } from '@/components/product-list';

interface Product {
  id: string;
  name: string;
  price: number;
}

export default async function ProductsPage() {
  // ✅ Explicitly define the cache lifetime (e.g., 60 seconds)
  const res = await fetch('https://api.example.com/products', {
    next: { 
      revalidate: 60,
      tags: ['products'] // Recommended for on-demand invalidation
    }
  });

  if (!res.ok) {
    throw new Error('Failed to fetch products');
  }

  const data = (await res.json()) as Product[];
  
  return <ProductList data={data} />;
}

The Solution (Immutable Cache): If the data never changes (e.g., blog posts, configuration), explicitly force the cache.

const res = await fetch('https://api.example.com/config', {
  cache: 'force-cache' // ✅ Restore v14 "forever" caching behavior
});

2. Memoizing Database Queries (unstable_cache)

If you are connecting directly to a database (Prisma, Drizzle, etc.) rather than using fetch, Next.js 15 will run that query on every render. You must wrap these calls in unstable_cache.

Note: While named "unstable", this API is stable enough for production use in v15 until the new use cache directive stabilizes.

// lib/db-queries.ts
import { db } from '@/lib/drizzle'; // Example ORM
import { unstable_cache } from 'next/cache';

export const getCachedUserStats = unstable_cache(
  async (userId: string) => {
    // This heavy query now runs once per hour, not per request
    return await db.query.stats.findFirst({
      where: (stats, { eq }) => eq(stats.userId, userId),
    });
  },
  ['user-stats'], // Key parts
  { 
    revalidate: 3600, // 1 hour
    tags: ['user-stats'] 
  } 
);

3. Fixing GET Route Handlers

In v15, GET Route Handlers are no longer static by default if they detect any dynamic request information (like cookies or headers), and often default to dynamic even without them depending on your config.

To force a Route Handler to be static and cached (equivalent to a pre-rendered JSON file), you must use the dynamic route segment config options.

The Fix:

// app/api/categories/route.ts
import { NextResponse } from 'next/server';

// ✅ Explicitly opt-in to static generation
export const dynamic = 'force-static'; 
// OR
// export const revalidate = 3600;

export async function GET() {
  const data = await fetchCategoriesFromCMS();
  
  return NextResponse.json({ data }, {
    status: 200,
    headers: {
      'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=59',
    },
  });
}

async function fetchCategoriesFromCMS() {
  // Mock simulation
  return ['electronics', 'books', 'clothing'];
}

Why This Works

The shift in Next.js 15 forces developers to treat caching as an architectural decision rather than a magical framework feature.

  1. Predictability: By explicitly defining revalidate: 60, you are telling Next.js exactly how stale your application is allowed to be. There is no ambiguity.
  2. Granularity: You can mix no-store (real-time data) and force-cache (static assets) on the same page.
  3. Tags: Adding tags allows you to utilize revalidateTag in Server Actions, giving you event-driven caching (e.g., clearing the cache immediately after an admin updates a product) while keeping the default time-based cache as a fallback.

Conclusion

The "uncached by default" strategy in Next.js 15 is technically superior for correctness but dangerous for performance if ignored during upgrades.

Audit your fetch calls and DB queries immediately. If a request does not require real-time data, add next: { revalidate: N }. If you are relying on Route Handlers for static JSON, export dynamic = 'force-static'. Explicit configuration is the only way to ensure your API bills don't skyrocket post-migration.