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:
// 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:
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:
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:
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:
'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:
'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:
// 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:
'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:
'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
usersand another withuser-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
revalidateTagorrevalidatePathafter 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:
fetchis not cached unless you opt in withcache: 'force-cache'ornext.revalidate. - Use
cache: 'force-cache'for static data that rarely changes, andnext.revalidate: secondsfor 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 withrevalidateTagfrom Server Actions. - Use
revalidatePathwhen 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.






Comments
Loading comments…