React `cache()` and Next.js `"use cache"` Practical Guide — Two Ways to Eliminate Server Query Duplication in App Router
While checking dashboard performance, I discovered a strange pattern in the server logs. The same user query was executing three times while rendering the same page. <Header>, <Sidebar>, and <ProfileWidget> were each independently querying the database. It was a problem that could be solved directly at the code level without needing to attach Redis or modify the infrastructure.
This article is intended for frontend developers using Next.js App Router. It contains content that can be immediately applied if you have experience using Prisma or a similar ORM and understand the basic concepts of Server Components and Client Components.
After reading this article, you will understand how to distinguish and use React's cache() and Next.js's "use cache" directives according to their purpose, and how to reduce unnecessary server load through tag-based cache invalidation.
Key Concepts
React cache() — Memoization within a single request
cache() is a function that was introduced experimentally in React 18 and confirmed as a stable API in React 19. It memoizes the results of function calls in a Server Component environment and reuses the results of functions called with the same arguments within the same request (render tree). Even if multiple components simultaneously request high-cost functions, such as database queries or external API calls, the actual execution occurs only once.
// lib/user.ts
import { cache } from 'react';
import { prisma } from '@/lib/db';
export const getUserById = cache(async (id: string) => {
console.log(`DB 쿼리 실행: ${id}`); // 같은 요청 내에서 한 번만 출력됩니다
return await prisma.user.findUnique({ where: { id } });
});Now, even if the <UserAvatar> and <UserProfile> components call getUserById('123') simultaneously on the same page, the DB query is executed only once.
Memoization: An optimization technique that caches the result of a function call and returns the cached value when the same input is received. The cache of cache() is automatically cleared when a server request is finished, so you can use it without worrying about data leakage between requests.
The Pitfalls of Passing Objects as Arguments
cache() compares arguments using reference equality. If a new object literal is passed as an argument every time, the cache will not be hit at all.
// ❌ 매 렌더마다 새 객체가 생성되어 캐시 미스 발생
const user = await getUser({ id: '123' });
// ✅ 원시값(string, number)을 인자로 사용하면 캐시가 정상 적중합니다
const user = await getUserById('123');It is recommended to design the arguments of cache functions to be primitive types, such as strings or numbers, whenever possible.
Next.js "use cache" — Server caching across requests
The "use cache" directive is a feature stabilized in Next.js 15/16 and is declared at the top of a file, function, or component, like "use server" and "use client". It stores the execution results of the unit in a cache that persists between requests, and you do not need to manage key conflicts directly because the compiler automatically includes argument and closure values in the cache key.
Important: "use cache" works only in Server Components and server functions. Declaring it in a Client Component will cause an error, so it is recommended to check the server-client boundary first before applying it.
// components/ProductCard.tsx
import { cacheLife, cacheTag } from 'next/cache';
async function ProductCard({ id }: { id: string }) {
'use cache';
cacheLife('hours'); // stale/revalidate/expire 프로파일 설정
cacheTag(`product-${id}`); // 태그 기반 선택적 무효화
const product = await fetchProduct(id);
return <div>{product.name}</div>;
}Cache Boundary: Refers to the boundary of a component or function where "use cache" is declared. The entire execution result within this boundary is serialized and stored in the cache, and if the same input is received, it is returned directly from the cache without execution.
cacheLife Default Profile — stale / revalidate / expire mapping
cacheLife() is a convenience API that sets the three values stale, revalidate, and expire at once. The default profiles provided by Next.js are as follows.
| Profile | stale | revalidate | expire |
|---|---|---|---|
'seconds' |
0 sec | 1 sec | 1 sec |
'minutes' |
5 min | 1 min | 1 hour |
'hours' |
15 min | 1 hour | 1 day |
'days' |
1 hour | 1 day | 1 week |
'weeks' |
1 day | 1 week | 1 month |
'max' |
1 day | 1 month | Unlimited |
In addition to the default profile, you can also define custom profiles.
// next.config.ts
const nextConfig = {
experimental: {
cacheLife: {
'product-list': {
stale: 60, // 1분
revalidate: 300, // 5분
expire: 3600, // 1시간
},
},
},
};Relationship between the two APIs — Complementary layers
The two APIs have completely different cache validity ranges.
| Category | cache() |
"use cache" |
|---|---|---|
| Cache scope | In-request | Persistence across requests |
| Initialization time | Automatic initialization upon request termination | revalidateTag or TTL expiration |
| Standard Status | React Official API (React 19 stable) | Next.js Specific Directive |
| Main Objective | Elimination of duplicate function calls within a single request | Server and edge-level persistent caching |
| Usage Location | Server Component, Library Function | Top of Server Component, Server Function |
Using the two APIs together is more effective. By eliminating duplicate calls within a request with cache() and persistently caching results between requests with "use cache", you can minimize server load in two steps.
Now, let's look at how these two concepts are actually combined in code.
Practical Application
Example 1: Case where multiple components request the same data (cache())
On the dashboard page, the three components <Header>, <Sidebar>, and <ProfileWidget> all require current user information. If implemented without cache(), the DB query is executed three times.
// lib/auth.ts
import { cache } from 'react';
import { prisma } from '@/lib/db';
import { getSession } from '@/lib/session';
export const getCurrentUser = cache(async () => {
const session = await getSession();
if (!session?.userId) return null;
return await prisma.user.findUnique({
where: { id: session.userId },
select: { id: true, name: true, email: true, avatarUrl: true },
});
});// app/dashboard/page.tsx
import { Header } from '@/components/Header';
import { Sidebar } from '@/components/Sidebar';
import { ProfileWidget } from '@/components/ProfileWidget';
export default async function DashboardPage() {
return (
<div>
<Header /> {/* 내부에서 getCurrentUser() 호출 */}
<Sidebar /> {/* 내부에서 getCurrentUser() 호출 */}
<ProfileWidget /> {/* 내부에서 getCurrentUser() 호출 */}
</div>
// → 실제 DB 쿼리는 단 한 번만 실행됩니다
);
}| Points | Description |
|---|---|
| Call without arguments | Since getCurrentUser() has no arguments, it runs globally only once per request |
| Remove props drilling | Each component calls independently without needing to pass the user down from the parent |
| Automatic cache clearing | The cache is cleared after each request to ensure the latest data for subsequent requests |
Example 2: Product Page Caching and Optional Invalidation ("use cache" + cacheTag)
Product information does not change frequently, but when inventory changes, only the corresponding product cache needs to be updated immediately. By combining cacheTag and revalidateTag, you can precisely invalidate only specific products without clearing the entire cache.
// components/ProductDetail.tsx
import { cacheLife, cacheTag } from 'next/cache';
import { fetchProduct } from '@/lib/api';
async function ProductDetail({ productId }: { productId: string }) {
'use cache';
cacheLife('hours'); // stale 15분, revalidate 1시간, expire 1일
cacheTag(`product-${productId}`); // 상품별 태그 부여
const product = await fetchProduct(productId);
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>재고: {product.stock}개</span>
</article>
);
}// app/actions/inventory.ts
'use server';
import { revalidateTag } from 'next/cache';
import { prisma } from '@/lib/db';
export async function updateStock(productId: string, stock: number) {
await prisma.product.update({
where: { id: productId },
data: { stock },
});
// 해당 상품 캐시만 선택적으로 무효화됩니다
revalidateTag(`product-${productId}`);
}| Points | Description |
|---|---|
cacheLife('hours') |
Set stale/revalidate/expire at once with a predefined profile |
cacheTag(tag) |
Tagging cached items during rendering |
revalidateTag(tag) |
Invalidates only cached items with the corresponding tag in Server Action |
Example 3: Configuring a Blog Page with the PPR Pattern
This is a Partial Prerendering (PPR) pattern that immediately returns static content (body, title) from the cache and streams dynamic content (comments, views) via Suspense.
Starting with Next.js 15, params has been changed to the Promise type, so you must use await. The previous method (directly accessing params.slug) will result in an error in Next.js 15 or later.
// app/blog/[slug]/page.tsx
import { Suspense } from 'react';
import { CachedPostContent } from '@/components/CachedPostContent';
import { DynamicComments } from '@/components/DynamicComments';
import { ViewCounter } from '@/components/ViewCounter';
export default async function BlogPage({
params,
}: {
params: Promise<{ slug: string }>; // Next.js 15+: params는 Promise 타입
}) {
const { slug } = await params; // 반드시 await로 unwrap
return (
<>
{/* 정적 셸: 캐시에서 즉시 반환 */}
<CachedPostContent slug={slug} />
{/* 동적 콘텐츠: Suspense로 스트리밍 */}
<Suspense fallback={<p>댓글 불러오는 중...</p>}>
<DynamicComments slug={slug} />
</Suspense>
<Suspense fallback={<p>-</p>}>
<ViewCounter slug={slug} />
</Suspense>
</>
);
}// components/CachedPostContent.tsx
import { cacheLife, cacheTag } from 'next/cache';
import { fetchPost } from '@/lib/cms';
async function CachedPostContent({ slug }: { slug: string }) {
'use cache';
cacheLife('days');
cacheTag(`post-${slug}`);
const post = await fetchPost(slug);
return (
<main>
<h1>{post.title}</h1>
{/*
CMS에서 받은 HTML을 렌더링할 때는 XSS 위험에 주의가 필요합니다.
신뢰할 수 없는 출처의 콘텐츠라면 DOMPurify 같은 라이브러리로
sanitize한 후 사용하는 것을 권장합니다.
*/}
<div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
</main>
);
}Partial Prerendering (PPR): Next.js's hybrid rendering strategy that returns statically renderable parts (shells) from the cache first and populates dynamic parts via streaming. When combined with the "use cache" directive, cached static shells can be displayed immediately and dynamic content populated afterward, significantly improving perceived loading speed.
Pros and Cons Analysis
Advantages
| Item | Content |
|---|---|
| Declarative Caching | Code readability is improved by allowing you to specify cache boundaries with a single line of directives |
| Automatic Cache Key Generation | The compiler automatically includes argument and closure values in the cache key to minimize key conflict errors |
| Granular Invalidation | You can selectively update only specific data with cacheTag + revalidateTag |
| PPR Integration | Improve Live Performance (LCP) with Static Shell Immediate Return + Dynamic Streaming |
| Server-Client Integration | Server cache and client navigation cache are synchronized with a single configuration |
Disadvantages and Precautions
| Item | Content | Response Plan |
|---|---|---|
| Serverless Environment Limitations | In-memory caches are not shared between instances, so cache misses may occur frequently | Connect external cache stores such as Redis and Upstash to cacheHandler in next.config.ts |
| Dynamic values cannot be accessed | Runtime values such as cookies(), searchParams, etc. cannot be accessed directly within "use cache" |
Pass runtime values as arguments or process them outside the cache boundaries |
| Risk of sensitive data exposure | Public cache may contain user-specific personalized data | Separate personalized content outside cache boundaries or isolate it by including the user ID in cacheTag |
| Debugging Complexity | Tracing invalidations may be difficult when using multi-layered caches (memory, remote, and client) | Manage tags using consistent naming conventions (entity-id format) |
| Next.js only | The "use cache" directive is dependent on Next.js and is not a React standard API |
Use the React standard cache() for framework-independent code |
| Cache miss for object arguments | Since cache() compares arguments by reference equality, object literal arguments do not hit the cache |
Design arguments as primitive types (string, number) |
The Most Common Mistakes in Practice
- When directly calling
cookies()orheaders()from inside"use cache": Runtime request information cannot be accessed within the cache boundary. It works correctly if you separate these values by reading them outside the cache boundary and passing them as arguments. - When applying a public cache to personalized data: Storing a logged-in user's cart or recommended products in a public cache poses a risk of exposure to other users. It is recommended to render personalized content outside the cache boundaries or isolate it by including the user ID in
cacheTagin the formcacheTag(user-cart-${userId}). - Confusion between
cache()and"use cache": The two APIs have completely different cache retention ranges.cache()is initialized when the request ends, while"use cache"persists until the set TTL orrevalidateTagis called. For example, caching personalized data that needs to change per session in"use cache"can unintentionally expose previous user data, so it is important to distinguish and use them according to their purpose.
In Conclusion
By eliminating duplicate calls within requests with React cache() and declaring cross-request caching with Next.js "use cache", you can substantially improve server rendering performance without increasing code complexity.
There is no need to introduce both APIs simultaneously. We recommend starting small.
3 Steps to Start Right Now:
- Start by wrapping common data functions with PLACEHOLDER_89__. Apply
import { cache } from 'react'to functions likegetUserByIdandgetConfigthat are called identically across multiple components, and you can verify in the server logs that the number of executions decreases. - Declare
"use cache"for components that do not change frequently. If you are using Next.js 15 or later, you can start by addingexperimental: { dynamicIO: true }tonext.config.ts(which will be switched to the default in Next.js 16), and then declaring'use cache'andcacheLife('days')at the top of the global navigation or footer components. - Connect the data change flow with
cacheTag+revalidateTag. AddcacheTag('my-data')to the cache component, and callrevalidateTag('my-data')in the Server Action that changes the data to complete the precise cache invalidation cycle.
Further topics to explore include connecting external cache handlers using Redis or Upstash, defining custom profiles, and standardizing tag naming strategies as team conventions.
Next Post: In-depth Analysis of Partial Prerendering (PPR) — How to Reduce First Contentful Paint to the Extreme by Combining Static Shells and Dynamic Streaming
Reference Materials
- React Official Documentation — cache()
- Next.js Official Documentation — use cache directive
- Next.js Official Documentation — cacheLife
- Next.js Official Documentation — cacheTag
- Next.js Official Documentation — Getting Started with Cache Components
- Next.js Official Documentation — CacheComponents Configuration
- Next.js Blog — Composable Caching
- Next.js 블로그 — Our Journey with Caching
- Vercel Academy — Cache Components for Instant and Fresh Pages
- LogRocket Blog — Cache components in Next.js
- Prismic Blog — Next.js Cache Components: 4 Ways to Balance Speed and Cost
- DEV Community — Next.js Caching Explained: Every Strategy You Need to Know
- Strapi Blog — Mastering Next.js 15 Caching: dynamicIO, "use cache" & More
- Next.js 16 Official Release Notes