Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 027 / 100

Caching in modern Next.js for React & Next.js developers

Next.js 15 flipped the defaults. Know them.

Expona Admin7 min read

If you've upgraded to Next.js 15 recently, you may have noticed your data endpoints getting hammered more than usual. That's because caching in modern Next.js changed fundamentally—fetch requests no longer cache by default. This shift forces you to be explicit about when and how you cache, which is actually a good thing once you understand the new model.

Fetch is no longer cached by default

For years, Next.js developers relied on the implicit caching behavior of fetch. In App Router, every fetch call automatically received a 24-hour cache. That meant running the same query twice would hit your database once. It was convenient, but it also hid performance decisions—developers often didn't realize what was and wasn't cached.

Next.js 15 flipped that default. Now, fetch requests are not cached unless you explicitly tell them to be. This means if you have a function that runs during server-side rendering and you're fetching fresh data every time, you're making that request fresh every request. For a high-traffic application, that's expensive.

Here's what changed:

tsx
// Next.js 14 behavior (still applies if you opt in)
export default async function Dashboard() {
  // This was cached for 24 hours automatically
  const data = await fetch('https://api.example.com/user');
  return <div>{data.name}</div>;
}

// Next.js 15 behavior (default)
export default async function Dashboard() {
  // This is NOT cached. Fresh request every time.
  const data = await fetch('https://api.example.com/user');
  return <div>{data.name}</div>;
}

I built a real-time trading dashboard where this caught me off-guard. Pricing data was being fetched fresh on every request instead of once per minute. My API bill jumped immediately. The lesson: Next.js 15 assumes you want fresh data by default, which is the right assumption for most modern applications, but you need to opt back in when you want caching.

Opt in with cache: 'force-cache' or revalidate

Now that caching is opt-in, you control it via the options object passed to fetch. Two approaches work here: absolute caching with cache: 'force-cache', or time-based revalidation.

Force-cache is the nuclear option. It caches the response indefinitely until you manually revalidate:

tsx
export default async function ProductListing() {
  const products = await fetch('https://api.example.com/products', {
    cache: 'force-cache'
  });
  
  return (
    <ul>
      {products.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  );
}

I use this for static content like product catalogs, documentation, or configuration data that rarely changes. It's fast and cheap.

Revalidation is more nuanced. You can specify how long a cached response is valid using the next.revalidate option. This is called Incremental Static Regeneration (ISR) in the Next.js docs:

tsx
export default async function Dashboard() {
  // Cache for 1 hour (3600 seconds)
  const data = await fetch('https://api.example.com/stats', {
    next: { revalidate: 3600 }
  });
  
  return <div>Stats: {data.value}</div>;
}

This is what I reach for in most cases. The CRM platform I work on uses a 5-minute revalidation for user profiles (fresh enough for most operations, cheap enough to scale). You balance freshness against cost and server load.

You can also disable caching entirely by setting cache: 'no-store', which is useful for real-time data:

tsx
export default async function LivePrices() {
  const prices = await fetch('https://api.example.com/prices', {
    cache: 'no-store'
  });
  
  return <div>Current price: ${prices.usd}</div>;
}

Next.js 16 adds 'use cache' with Cache Components

Next.js 16 introduces a new directive: 'use cache'. This is a finer-grained caching mechanism that works at the component level, not just individual fetch calls. It's still experimental, but it's worth understanding because it represents where server components are headed.

The idea is simple: annotate a component or function with 'use cache' and everything inside it—all fetches, all computations—gets cached together:

tsx
'use cache';

export default async function UserCard({ userId }) {
  // Both of these fetches are cached as a unit
  const user = await fetch(`https://api.example.com/users/${userId}`);
  const posts = await fetch(`https://api.example.com/posts?userId=${userId}`);
  
  return (
    <div>
      <h3>{user.name}</h3>
      <p>Posts: {posts.length}</p>
    </div>
  );
}

You can also scope cache with tags, similar to how you'd use revalidateTag:

tsx
'use cache';
import { cache } from 'react';

export const getUser = cache(async (userId: string) => {
  return fetch(`https://api.example.com/users/${userId}`);
}, ['users']);

In practice, this is still stabilizing. I haven't shipped it to production yet, but it's going to be useful for nested component trees where you want to cache a subtree without managing individual fetch calls. It's cleaner than spreading cache options across dozens of API calls.

revalidateTag and revalidatePath for on-demand updates

The real power emerges when you need to invalidate cached data on-demand. Suppose you cache product listings for 1 hour, but you just updated the inventory. You don't want users to see stale data for the next 50 minutes.

revalidateTag lets you tag related fetches and purge them all at once:

tsx
// In your page or component
export default async function ProductPage({ productId }) {
  const product = await fetch(
    `https://api.example.com/products/${productId}`,
    {
      next: { tags: ['products', `product-${productId}`] }
    }
  );
  
  return <div>{product.name}</div>;
}

Then in a Server Action (e.g., in a form handler or API route), you revalidate by tag:

tsx
'use server';
import { revalidateTag } from 'next/cache';

export async function updateProduct(productId, newData) {
  // Update your database
  await db.products.update(productId, newData);
  
  // Purge all cached data tagged with this product
  revalidateTag(`product-${productId}`);
  
  // Or purge all product caches
  revalidateTag('products');
}

I've used this extensively in the e-commerce platform. When an admin updates a product, we revalidate the tag immediately. Customers see fresh data within milliseconds, and we've kept the bulk of the cache warm for unrelated products.

revalidatePath takes a different approach. Instead of tagging fetches, it revalidates all cached data on a specific route:

tsx
'use server';
import { revalidatePath } from 'next/cache';

export async function createPost(formData) {
  const title = formData.get('title');
  const content = formData.get('content');
  
  // Save to database
  const post = await db.posts.create({ title, content });
  
  // Revalidate the entire posts list page
  revalidatePath('/posts');
  
  // Or a specific post page
  revalidatePath(`/posts/${post.id}`);
}

I reach for revalidatePath when the relationship between fetches is implicit or complex. For instance, a user profile page might fetch user info, comments, followers, and settings. Tagging each individually is tedious. Revalidating the entire path is simpler.

The trade-off: revalidateTag is surgical and efficient; revalidatePath is broad and can purge more cache than necessary. Choose based on how granular your caching needs are.

Common mistakes

  • Assuming fetch is cached by default. It's not in Next.js 15+. You'll make unnecessary requests and blow through your API quota. Always audit which fetches are cached and which aren't.
  • Using revalidate: 0 for everything. This disables caching and defeats the purpose of server-side rendering. Reserve it for truly real-time data (live prices, scores, stock levels). Most data can wait 5–60 minutes.
  • Tagging fetches inconsistently. If you tag one fetch with users and another with user-data, you'll have to revalidate both separately. Standardize your tag names across your app.
  • Forgetting to revalidate after mutations. I've shipped code where users create posts, but the listing page still shows the old cache. Always call revalidateTag or revalidatePath after database writes.
  • Over-caching in development. Use cache: 'no-store' or short revalidation windows (5–10 seconds) while building. I've wasted hours debugging "broken" features because stale cache was hiding my changes.

Key takeaways

  • Next.js 15 changed the default: fetch is not cached unless you opt in with cache: 'force-cache' or next.revalidate.
  • Use cache: 'force-cache' for static data that rarely changes, and next.revalidate: seconds for data that changes periodically.
  • Set cache: 'no-store' for real-time data where freshness matters more than performance.
  • Tag your fetches with next: { tags: [] } and purge them surgically with revalidateTag from Server Actions.
  • Use revalidatePath when you need to purge all cached data on a route without managing individual tags.
  • Next.js 16's 'use cache' directive is coming; it will let you cache entire component subtrees, but it's still experimental.
  • Always revalidate after mutations. A stale cache is silent, invisible, and will frustrate users.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles