
How does caching work in Next.js 15? A practical introduction with examples
In the world of JavaScript frameworks, Next.js has over the years built a reputation as a tool that combines the flexibility of React with server optimization capabilities. In version 15, Next.js developers made a breakthrough in their approach to caching - ditching the default aggressive caching in favor of more control on the developer side. This article is a guide to the new caching model in Next.js 15. You'll learn how the new 'use cache' directive works, what dynamicIO is, and how to practically use these features to make your applications not only faster, but also more predictable in performance.

Why is cache so important?
Caching is a technique that allows an application to remember previously downloaded data and avoid unnecessary network queries or costly calculations. A well-designed cache can dramatically speed up page loading and reduce server load. But caching also has its dark side: out-of-date data, erroneous revalidations and loss of control over what refreshes when.
Next.js until version 14 used by default mechanisms that automatically cached data from fetch
. The problem was that this often led to unexpected results – especially in dynamic applications. In Next.js 15, the philosophy changes: by default, the cache is disabled, and the responsibility for making it work rests with the developer.
This approach gives more control, but requires an understanding of how the new model works and when it is worth using it.
New caching model in Next.js 15 – what has changed?
The biggest change is that asynchronous functions are no longer cached by default. In previous versions, when you used fetch
in a server component, Next.js could automatically cache the data and serve it as static. From now on, for something to be cached, you must explicitly declare it.
What does this mean in practice?
Example – suppose you have a function that retrieves data from an API:
async function getUser(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`);
return res.json();
}
In Next.js 14, this function could be automatically cached. In Next.js 15 – no. The data will be fetched anew with each request, unless you give a signal that you want to cache it.
To restart the caching mechanism – you use the new 'use cache'
directive (more about it in a moment).
New: dynamicIO
Next.js 15 also introduces an experimental mode called dynamicIO, which changes the way the framework treats dynamic functions. By default, Next.js considers everything dynamic (i.e., requiring SSR), but with dynamicIO
you can mark specific parts of your application as cache-safe or even static – giving you big performance gains.
To activate it, simply add to next.config.js
:
experimental: {
serverActions: true,
dynamicIO: true
}
This opens the door to deeper management of what is dynamic and what is not – and achieves a balance between data freshness and performance.
Directive 'use cache'
– full control over cache
The new 'use cache'
directive is one of the most important tools in Next.js 15. It allows us to explicitly specify which functions should be cached – and for how long.
How does 'use cache'
work ?
It is simply a special line added at the beginning of a file (or function) that says: “this function is to be cached”.
'use cache';
export async function getUser(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`);
return res.json();
}
From this point on, getUser
becomes a function whose result is stored in a cache and is not queried again with each request – until it is invalidated or the cache lifetime (if we have defined one) expires.
Where can you use 'use cache'
?
Use cache can be used in:
– in asynchronous functions
– in server components
– in data loader helpers
– at the level of entire layouts or pages
Example with component:
'use cache';
export default async function Profile({ userId }: { userId: string }) {
const user = await getUser(userId); // also with 'use cache'
return <div>{user.name}</div>;
}
In this case, the component and the getUser
function are cached, which means that user data will be retrieved only once for the life of the cache.
Configuring the Next.js 15 project with dynamicIO
Next.js 15 introduces a new mechanism called dynamicIO
, which allows for more precise management of what can be cached and what should remain dynamic. This tool not only improves performance, but also simplifies complex scenarios where some data can be static and some dynamic – and within a single page or component.
How to enable dynamicIO
?
To activate dynamicIO
, you need to add the appropriate flag in the file next.config.js
. In practice, it looks like this:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: true,
dynamicIO: true,
},
};
module.exports = nextConfig;
Practical note: dynamicIO
only works in applications using the App Router (/app
), not with the older Pages Router(/pages
). It’s also worth making sure you have an up-to-date version of Next.js 15 and Node 18+.
What does dynamicIO
change ?
Bottom line:
- allows Next.js to analyze data flow at compile time.
- enables
'use cache'
to be used optimally – the framework knows which functions can be safely cached. - avoids accidental SSR – which has often been a problem in the past (e.g. by accidental use of
headers()
orcookies()
).
A practical example
Suppose you have a page /blog/[slug]
, which displays a post based on the slug. You want the content of the post to be cached (because it rarely changes), but the comments to be always up-to-date (i.e. dynamic).
Catalog structure:
app/
└── blog/
└── [slug]/
└── page.tsx
getPost.ts
(cached function):
'use cache';
export async function getPost(slug: string) {
const res = await fetch(`https://cms.example.com/api/posts/${slug}`);
if (!res.ok) throw new Error('Post not found');
return res.json();
}
getComments.ts
(always dynamic):
export async function getComments(slug: string) {
const res = await fetch(`https://cms.example.com/api/comments/${slug}`, {
cache: 'no-store', // enforcing no cache
});
return res.json();
}
page.tsx
:
import { getPost } from '@/lib/getPost';
import { getComments } from '@/lib/getComments';
export default async function BlogPage({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug); // using cache
const comments = await getComments(params.slug); // dynamic
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<section>
<h2>Komentarze</h2>
<ul>
{comments.map((c) => (
<li key={c.id}>{c.text}</li>
))}
</ul>
</section>
</article>
);
}
The effect: The website will be generated faster because the main content is cached, but the user will always see the latest comments.
Tip: When you have a lot of data to cache, make sure you separate the layers:
– component (page.tsx
) – merges this data
This division allows you to test and debug cache behavior without unnecessary chaos in the view logic.
– data layer (lib/get*.ts
) – you mark 'use cache'
or not
Cache in practice – Next.js 15 integration with local API
You don’t need to use external services or a complicated backend to test how the cache works in Next.js 15. All you need is a local API endpoint that returns data – e.g. a list of products. This is enough to demonstrate how 'use cache'
, revalidation and how Next.js stores data at the server level work.
Let’s assume you are building a product page. The data comes from the local endpoint /api/products
, which returns a list of products from a JSON file (we are simulating the backend here).
1. API with product data (/api/products/route.ts
)
Let’s start with the local endpoint that provides the product list. This will be our data source – a simulation of the backend. We return the data in JSON format.
// app/api/products/route.ts
import { NextResponse } from 'next/server';
const products = [
{ id: 1, name: 'Laptop', price: 4500 },
{ id: 2, name: 'Keyboard', price: 350 },
{ id: 3, name: 'Mouce', price: 150 },
];
export async function GET() {
return NextResponse.json(products);
}
2. Function that retrieves data from the API(lib/getProducts.ts
)
We create a separate function for retrieving data. It is in it that we will mark the cache with 'use cache'
and specify the revalidation time.
// lib/getProducts.ts
'use cache';
export async function getProducts() {
const res = await fetch('http://localhost:3000/api/products', {
next: { revalidate: 60 }, // cache na 60 seconds
});
return res.json();
}
3. Product list page(app/products/page.tsx
)
Finally – a page that uses our function and displays data in the UI. The data will be fetched only once every 60 seconds (thanks to the cache), and then served from memory.
// app/products/page.tsx
import { getProducts } from '@/lib/getProducts';
export default async function ProductsPage() {
const products = await getProducts();
return (
<section>
<h1>Our Products</h1>
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name} – {p.price} zł
</li>
))}
</ul>
</section>
);
}
Users visiting the site at short intervals will see the cached data, which will speed up loading and reduce server load.
Managing cache lifetime and data revalidation
Simply caching data is just the beginning. In real applications, we need ways to determine when data should refresh and be able to control it in a precise and predictable manner.
Next.js 15 introduces several mechanisms that allow you to manage the cache life cycle:
revalidate
– data retention time (e.g. 60 seconds),cacheTag
– tagging of cached data,revalidateTag
– dynamic invalidation of data on the server side.
Below, we will go through each of these mechanisms with practical examples.
1. revalidate
– cache lifetime of data
The revalidate
field determines how long (in seconds) the result of a fetch
should be stored in the cache before it is fetched again.
Example:
// lib/getNews.ts
'use cache';
export async function getNews() {
const res = await fetch('https://api.example.com/news', {
next: { revalidate: 120 }, // cache for 2 minutes
});
return res.json();
}
This means: the data is cached for 2 minutes. After this time, Next.js can download a fresh version, but the old version can still be served until updated – the so-called constant-while-revalidate.
Tip: use revalidate for data that rarely changes, such as blog posts, products, categories.
2. CacheTag – tagging cached data.
When you want to group and control the cache for related data (e.g. all blog posts), you can assign tags to them.
Example:
// lib/getPosts.ts
'use cache';
export async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: {
tags: ['posts'], // we assign the tag
},
});
return res.json();
}
This makes Next.js save the result of this query with the label "posts"
.
3. revalidateTag
– dynamic cache invalidation
And now the best part: you can remotely invalidate the cache with a specific tag. This is especially useful if your application has an admin panel from which you edit data.
Example:
// app/api/revalidate/posts/route.ts
import { revalidateTag } from 'next/cache';
import { NextResponse } from 'next/server';
export async function POST() {
revalidateTag('posts'); // invalidate all fetches with 'posts' tag
return NextResponse.json({ success: true });
}
Now when a user publishes a new post, you can call this API from either the admin panel or the webhook – and Next.js will remove the corresponding post from the cache and fetch fresh data on the next request.
Practical examples – caching in layouts, components and functions
Next.js 15 allows you to use cache not only in functions like fetch
, but also in server components, page layouts and even in asynchronous ‘helpers’. In this section, you will see how to effectively implement 'use cache'
at different levels of application architecture.
2. Layout-level caching – ideal for repetitive content
Layouts in App Router are a natural place to use cache, as they often contain fixed structures such as headers, footers, sidebars or menus.
// app/(main)/layout.tsx
'use cache';
import { getMenuItems } from '@/lib/getMenuItems';
export default async function MainLayout({ children }: { children: React.ReactNode }) {
const menu = await getMenuItems(); // cached function
return (
<div>
<nav>
<ul>
{menu.map((item) => (
<li key={item.href}>
<a href={item.href}>{item.label}</a>
</li>
))}
</ul>
</nav>
<main>{children}</main>
</div>
);
}
Menus are fetched only once (or according to revalidate
), so that the layout does not make unnecessary queries with each page.
2. Server components with cache – such as widgets or hero
Often you have components that appear on many pages and don’t change very often – for example, a banner with a promotion, a weather widget, a “recommended articles” block.
Example:
// components/HeroBanner.tsx
'use cache';
import { getPromoBanner } from '@/lib/getPromoBanner';
export default async function HeroBanner() {
const banner = await getPromoBanner();
return (
<section className="hero">
<h1>{banner.title}</h1>
<p>{banner.subtitle}</p>
</section>
);
}
In this way, the component manages its own cache – and can be used in multiple places without duplicating logic.
3. Asynchronous functions with cache – logic separated from the view
If you want to separate the data layer from the presentation layer, it’s a good idea to cache helper functions(data loaders
) that only return data – and components only render them.
Example:
// lib/getRecommendedPosts.ts
'use cache';
export async function getRecommendedPosts(category: string) {
const res = await fetch(`https://api.example.com/posts?category=${category}`, {
next: { revalidate: 300 },
// optional: tags: ['recommended'],
});
return res.json();
}
And usage:
// components/RecommendedSection.tsx
import { getRecommendedPosts } from '@/lib/getRecommendedPosts';
export default async function RecommendedSection({ category }: { category: string }) {
const posts = await getRecommendedPosts(category);
return (
<aside>
<h2>Recomended articles</h2>
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
</aside>
);
}
This way, the component doesn’t need to know anything about the cache – that’s the job of getRecommendedPosts
.
Challenges and best practices – how to avoid the pitfalls of caching
In the previous parts, we showed you how to implement caching in Next.js 15 step by step. Now it’s time for a practical look: what can go wrong, where typical mistakes lurk, and how to approach caching in a conscious, predictable, and scalable way.
Next.js 15 is extremely powerful, but like any advanced feature, it requires understanding and discipline. Below you will find a short list of the most common problems and best practices that will help you build fast, stable, and well-managed applications.
Common problems and errors
fetch
is not cached despite'use cache'
– because dynamic functions (headers()
,cookies()
) are used.- unaware overwriting of the cache – due to lack of
revalidate
or incorrect tags. - data is cached for too long – lack of automatic refresh.
- failure to invalidate the cache after data editing – e.g. via the admin panel.
- attempting to cache the client component (
'use client'
) – which does not work. - excessive caching of functions – which leads to unnecessary complication of logic.
Best practices
- start by caching data, not components – first
fetch it
, then layout. - use
revalidate
for variable data, e.g. every 60–300 seconds. - tag data (
cacheTag
) if you want to be able to invalidate a specific group. - cache only what is really worth it – not everything needs to be stored.
- separate logic from rendering – a component should only display data, not decide on its freshness.
- create separate functions for dynamic and static data – don’t mix approaches.
- document caching decisions – it is easier to maintain in a team.
- test cache behavior with development build (
next dev
) and production build (next build && start
) – they may differ!
Cache in Next.js 15 is not only an optimization tool, but a new way of thinking about application structure. By abandoning automatic caching and introducing mechanisms such as 'use cache'
, revalidate
, cacheTag
or dynamicIO
, the programmer gains real control over the performance and up-to-dateness of data.
In this article, we show you how to:
- work with the new caching model in Next.js 15,
- implement caching in functions, components and layouts,
- manage the data lifecycle and revalidation,
- avoid common mistakes and apply best practices.
The new system requires more awareness, but also provides more predictability and flexibility. This allows you to build an application that not only runs fast, but also provides users with up-to-date and reliable data – exactly when they need it.


