Caching in Modern React and Next.js: How to Use It Correctly
Caching is one of the most powerful ways to improve web application performance. But with React's cache() and Next.js's new Cache Components, understanding what to cache, how long to cache it, and when to invalidate it has become just as important as knowing how to implement it.
Caching has always been an important part of web development. Whether we are building an e-commerce platform, a blog, a dashboard, or a restaurant management system, we often fetch the same data repeatedly.
Why should we query the database every time a user opens a page if the data has not changed?
Why should every visitor trigger an identical API request for a blog post that was published several days ago?
And more importantly, how can we make our applications faster without accidentally serving outdated or user-specific data to the wrong person?
Modern React and Next.js offer several caching mechanisms that address these problems. However, they solve different problems, and using the wrong one can introduce bugs that are difficult to detect.
Let's explore how they work, when to use them, and how to implement them with practical examples.
1. First, Understand the Different Types of Caching
Before writing any code, we need to understand an important distinction: not all caches have the same lifetime or scope.
React and Next.js provide several mechanisms that may look similar but behave differently.
MechanismWhat it doesTypical use caseReact cache()Deduplicates work within a server requestAvoiding duplicate database queriesNext.js 'use cache'Caches function, component, or route output across requestsBlog posts, product catalogscacheLife()Defines cache lifetime and freshness behaviorControlling how frequently content refreshescacheTag()Associates cache entries with tagsInvalidating related contentupdateTag()Expires tagged data for immediate refresh in a Server ActionUpdating content that must immediately reflect a user's changerevalidateTag()Revalidates tagged data, with support for stale-while-revalidate behaviorRefreshing published contentrevalidatePath()Revalidates cached data associated with a routeUpdating a specific page after a mutation
These mechanisms are related, but they are not interchangeable.
For example, React's cache() does not create a persistent cache shared across incoming requests. Next.js's 'use cache', on the other hand, can reuse cached results across requests.
That distinction determines which solution we should choose.
2. React cache(): Avoid Repeating the Same Work
Let's start with React's cache() API.
Imagine that a product page contains three Server Components:
Product details
Product availability
Product recommendations
All three components need information about the same product. If each component independently queries the database, we may perform redundant work.
We can define a cached function in a shared module.
// lib/products.ts
import { cache } from 'react';
import { db } from '@/lib/db';
export const getProduct = cache(async (productId: string) => {
return db.query.products.findFirst({
where: (products, { eq }) =>
eq(products.id, productId),
});
});Now multiple Server Components can use the same function.
// app/products/[id]/page.tsx
import { getProduct } from '@/lib/products';
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
if (!product) {
return <p>Product not found.</p>;
}
return (
<main>
<h1>{product.name}</h1>
<p>{product.price}</p>
</main>
);
}If multiple Server Components call the same cached function with the same arguments during the same server request, React can reuse the result instead of repeating the work.
What are the benefits?
Fewer duplicate database queries.
Less repeated computation.
Shared results between components.
No need to introduce a persistent caching service for this specific purpose.
What is the limitation?
React's cache() is scoped to a server request. The result is not automatically stored for the next visitor.
If 1,000 visitors request the same product page, cache() alone does not provide a persistent shared product cache across those requests.
Use React cache() when you want to avoid duplicate work within a single server render, not when you need long-lived caching.
One implementation detail matters: define the cached function in a shared module rather than creating a new cache() wrapper inside each component. Otherwise, components may not share the same cache.
3. Next.js 'use cache': Cache Results Across Requests
Now let's look at the more powerful caching capabilities available in modern Next.js.
In Next.js 16, Cache Components can be enabled through the cacheComponents configuration option.
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;With this feature enabled, we can use the 'use cache' directive to cache the result of a function or component.
Let's use a blog as an example.
Imagine that an article rarely changes after publication. Fetching the same article from the database on every request is often unnecessary.
// lib/posts.ts
import { cacheLife, cacheTag } from 'next/cache';
import { db } from '@/lib/db';
export async function getPost(slug: string) {
'use cache';
cacheLife('hours');
cacheTag('posts', `post-${slug}`);
return db.query.posts.findFirst({
where: (posts, { eq }) => eq(posts.slug, slug),
});
}We can then use this function in a page.
// app/blog/[slug]/page.tsx
import { getPost } from '@/lib/posts';
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
return <h1>Article not found</h1>;
}
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}The cached function can reuse its result across requests, rather than repeating the database query for every visitor.
The cacheLife('hours') profile specifies the cache's lifetime behavior, while cacheTag() gives us a way to invalidate the relevant entries later.
The exact freshness behavior depends on the selected cache profile. A cached value may be served while revalidation is pending, so the cache lifetime should match the application's freshness requirements.
What are the benefits?
Faster responses for frequently requested content.
Fewer database and API calls.
Reduced load on backend services.
Better scalability when many users request the same content.
What are the limitations?
Data can become outdated until the cache is revalidated.
Cache keys and arguments must be designed carefully.
Cache storage and persistence depend on the deployment environment and configuration.
Personalized information requires additional care to avoid sharing data between users.
For public blog articles, this is often an excellent fit. For a user's private account details, it requires a different approach.
4. cacheLife(): Decide How Long Data Should Stay Cached
Caching data is only half the problem. We also need to decide when it should become stale or expire.
Next.js provides cacheLife() to control this behavior.
For example:
import { cacheLife } from 'next/cache';
export async function getCategories() {
'use cache';
cacheLife('days');
return db.query.categories.findMany();
}If categories change infrequently, caching them for longer periods may reduce unnecessary database queries.
By contrast, a product catalog may require a shorter freshness window.
export async function getProducts() {
'use cache';
cacheLife('hours');
return db.query.products.findMany();
}Next.js provides built-in profiles such as 'minutes', 'hours', 'days', and 'weeks'. You can also define custom profiles when the defaults do not fit your application.
The important point is that cacheLife() is not simply a switch between caching and not caching. It defines a freshness policy.
How should you choose a cache lifetime?
Data typePossible strategyPublished blog articlesLonger cache lifetimeProduct catalogModerate lifetime plus invalidation after updatesCategory listLonger lifetime if changes are infrequentFrequently updated dashboardShort lifetime or request-time fetchingUser-specific account dataAvoid shared public caching unless isolation is explicitly guaranteed
These are starting points, not universal rules. The correct policy depends on how frequently the data changes and how costly stale information would be.
5. cacheTag(): Invalidate Only What Has Changed
This is where caching becomes particularly useful in real applications.
Imagine that an administrator edits a blog article. We do not want to disable caching for the entire website just because one article changed.
Instead, we can associate cached entries with tags.
import { cacheLife, cacheTag } from 'next/cache';
export async function getPost(slug: string) {
'use cache';
cacheLife('days');
cacheTag('posts', `post-${slug}`);
return db.query.posts.findFirst({
where: (posts, { eq }) => eq(posts.slug, slug),
});
}Now we have two tags:
postsidentifies the broader collection.post-${slug}identifies the individual article.
We can invalidate the relevant entries when an administrator updates an article.
// app/blog/actions.ts
'use server';
import { updateTag } from 'next/cache';
import { db } from '@/lib/db';
export async function updatePost(
slug: string,
title: string,
content: string
) {
await db.update(/* your posts table */)
.set({ title, content })
.where(/* match the post by slug */);
updateTag(`post-${slug}`);
updateTag('posts');
}The database update above is illustrative: the actual query depends on your database library and schema.
updateTag() expires the tagged cache entries and is intended for Server Actions that need read-your-own-writes behavior. When the relevant data is requested again, Next.js can retrieve the updated version.
This is especially useful for content management systems, admin dashboards, and e-commerce platforms.
The principle is simple: cache aggressively when appropriate, then invalidate precisely when the underlying data changes.
6. updateTag() vs. revalidateTag(): What Is the Difference?
These APIs are easy to confuse, but their intended behavior differs.
Use updateTag() when a user expects an immediate update
For example, an administrator edits a blog article and expects to see the updated content immediately after saving.
'use server';
import { updateTag } from 'next/cache';
export async function publishPost(slug: string) {
// Save the published article to the database.
updateTag(`post-${slug}`);
}updateTag() is designed for Server Actions and immediate expiration of tagged cache entries.
Use revalidateTag() when stale-while-revalidate is acceptable
For content such as a public blog or product catalog, it may be acceptable to serve an existing cached version while the cache refreshes.
'use server';
import { revalidateTag } from 'next/cache';
export async function refreshPublishedPosts() {
revalidateTag('posts', 'max');
}The 'max' profile applies stale-while-revalidate behavior: stale content may be served while fresh content is fetched in the background.
This can improve responsiveness, but it is not the right choice when the user must immediately see the exact version they just saved.
The second argument matters. Modern Next.js versions expect you to choose the appropriate revalidation behavior rather than treating revalidateTag() as an unconditional immediate refresh.
7. What About fetch() Caching?
Many developers learned Next.js caching through fetch() options.
For example:
const response = await fetch(
'https://api.example.com/products',
{
cache: 'force-cache',
}
);This explicitly requests cached fetching behavior.
Alternatively, in a Next.js application with Cache Components enabled, we can place a data-fetching function inside a 'use cache' scope and control its lifetime with cacheLife().
import { cacheLife } from 'next/cache';
export async function getProductsFromApi() {
'use cache';
cacheLife('hours');
const response = await fetch(
'https://api.example.com/products'
);
if (!response.ok) {
throw new Error('Failed to fetch products');
}
return response.json();
}These approaches belong to different caching APIs, so it is important to understand which layer is responsible for caching.
In particular, do not assume that every fetch() request is cached automatically. Behavior depends on the Next.js version, configuration, and the caching mechanism you are using.
When using Cache Components, 'use cache' provides an explicit way to cache the result of a function or component, including its data-fetching work.
8. The Most Dangerous Caching Mistake: User-Specific Data
Caching can improve performance dramatically, but incorrect cache boundaries can become a security problem.
Consider this example:
// Do not use this pattern for private account information
// without explicit user isolation.
export async function getCurrentUserProfile() {
'use cache';
// Read the current user's profile here.
}The problem is that cached functions should not depend implicitly on request-specific information such as cookies or headers.
Next.js recommends reading request-specific values outside the cached scope and passing the necessary values as arguments when appropriate.
For example, if the authenticated user ID is securely obtained and validated before calling the cached function:
import { cacheLife } from 'next/cache';
export async function getPublicUserProfile(userId: string) {
'use cache';
cacheLife('minutes');
return db.query.users.findFirst({
where: (users, { eq }) => eq(users.id, userId),
columns: {
id: true,
displayName: true,
avatarUrl: true,
},
});
}This example is appropriate only for data that is safe to cache and share according to the application's access rules. The user ID must come from a trusted, validated authentication flow, and the cached result must not expose private information.
Passing a user ID as an argument helps distinguish cache entries, but it does not replace authorization checks.
For sensitive account data, permissions, or financial information, the safest strategy may be to avoid shared persistent caching entirely.
Never assume that a cache key alone makes private data safe.
9. When Should You Avoid Caching?
Caching is not always the right solution.
You should reconsider caching when:
Data changes constantly and must always be fresh.
The cost of serving stale data is high.
Cache invalidation becomes more complicated than the performance benefit.
The result depends on request-specific information that cannot safely be shared.
The cached output contains information that must not be visible to other users.
For example, a restaurant management application may benefit from caching its public menu, restaurant description, and category list.
However, live table availability, incoming orders, payment status, and private customer information require much more careful freshness and isolation decisions.
A public menu can tolerate a brief delay after an update. A payment status should not be treated the same way.
The goal is not to cache everything. The goal is to identify which data can be reused safely and which data must be retrieved or validated at request time.
10. A Practical Caching Strategy for a New Next.js Project
If I were building a new application with modern Next.js, I would approach caching in the following order.
Step 1 — Identify the data.
Separate public content, slowly changing reference data, live operational data, and user-specific information.
Step 2 — Start with the simplest appropriate mechanism.
Use React cache() to avoid duplicate work within a server request. Consider Next.js 'use cache' when the result can be reused across requests.
Step 3 — Define freshness intentionally.
Choose an appropriate cacheLife() profile based on how frequently the underlying data changes.
Step 4 — Add tags when the data has a clear update workflow.
Use cacheTag() to associate cached entries with the content they represent.
Step 5 — Invalidate after successful mutations.
Use updateTag() for immediate read-your-own-writes behavior in Server Actions, or revalidateTag() with an appropriate profile when stale-while-revalidate is acceptable.
Step 6 — Measure the results.
Compare response times, database query counts, cache hit rates where available, and the correctness of updated content.
Step 7 — Test edge cases.
Check what happens when content is updated, deleted, published, or accessed by different authenticated users.
Caching is not complete when the data is stored. It is complete when the application serves the correct data at the correct time.
Conclusion
Modern React and Next.js give developers more control over caching than ever before.
React's cache() helps avoid duplicated work during a server request. Next.js's 'use cache' can reuse results across requests, while cacheLife(), cacheTag(), updateTag(), and revalidateTag() help define how cached data stays fresh.
But these APIs are not interchangeable, and adding caching everywhere does not automatically make an application better.
A good caching strategy starts with three questions:
Can this data safely be reused?
How stale can it become before that matters?
What should happen when the data changes?
Answer those questions first, and the implementation becomes much easier to reason about.
My guiding principle is simple:
Cache what can be reused. Invalidate what has changed. Never sacrifice correctness for speed.
Because the best-performing application is not the one that caches the most data. It is the one that delivers the right data efficiently, reliably, and securely.
Further Reading
React
cache()— Official DocumentationNext.js
'use cache'— Official DocumentationNext.js
cacheLife()Next.js
cacheTag()Next.js
updateTag()Next.js
revalidateTag()