This article is published in English.
Deduplicating ORM Queries Across a Next.js Render with React cache()
Learn why colocated Server Components can query the same record several times per request, how to confirm it, and how React cache() fixes it without prop drilling.
A Next.js route can feel fast in the browser while its database quietly answers the same question three or four times for every page view. The culprit is rarely a slow query; it is an ordinary lookup repeated across rendering boundaries that look independent in your code: generateMetadata(), the page, a breadcrumb, a nested Server Component. This article shows how that duplication arises, how to prove it is really happening, and how to remove it with React cache() while keeping data access close to the components that need it. It also draws a firm line between this per-request memoization and persistent caching, which answers a different question entirely.
How one page view turns into four lookups
Take a dynamic product route:
/products/[slug]
Several parts of that route need the same product. generateMetadata() wants its name and description for the document head. The page wants the full record. Breadcrumbs need the category. A nested Server Component might show price or stock status. Each of those consumers can sensibly load what it needs on its own. The metadata function does it like this:
export async function generateMetadata({
params,
}: PageProps<'/products/[slug]'>) {
const { slug } = await params
const product = await getProduct(slug)
return {
title: product.name,
}
}
The page component does the same thing:
export default async function ProductPage({
params,
}: PageProps<'/products/[slug]'>) {
const { slug } = await params
const product = await getProduct(slug)
return <ProductDetails product={product} />
}
And somewhere deeper in the tree, another Server Component independently calls:
const product = await getProduct(slug)
From a component-design point of view this is exactly right. Each piece of UI requests its data where it uses it, and responsibilities stay clear. The problem only shows up when you look at the other side: database query logs or upstream API metrics. A single incoming request can produce several identical product lookups. One response goes out to the browser, yet building it may have cost the database four round trips.
What Next.js already deduplicates, and what it does not
There is an important distinction to get right before changing anything. Next.js automatically memoizes identical native fetch requests made while rendering the React component tree, and its documentation calls out that this applies across generateMetadata, layouts, pages and Server Components. If getProduct() is built on fetch, those duplicate calls may already collapse into one.
When the data comes from somewhere other than fetch (an ORM, a database driver, a third-party SDK), there is no automatic memoization. For that case Next.js recommends React cache() as the way to share repeated work within a request.
The underlying insight is worth stating plainly: the performance cost is often not one expensive request but a cheap-looking operation repeated across boundaries that the framework makes easy to compose independently. The fix is not to move every query into one enormous parent component. It is to give the repeated data access a single shared identity.
Colocation makes repeated work invisible
With Server Components, keeping data access next to its consumer is the natural style. A component that renders product details can load the product itself, and breadcrumbs do not have to receive a large product object threaded through unrelated components just because an ancestor happened to load it first.
Without that freedom, the usual attempt to avoid duplicate work is to hoist all loading to the top of the route and pass results downward through every intermediate layer. That works, but it couples components that have no real relationship. The colocated alternative is for each Server Component to depend on one reusable data function:
const product = await getProduct(slug)
That keeps each requirement next to its consumer. The open question is whether those calls actually share a single underlying operation.
If they eventually issue identical native fetch requests, React memoizes them within the component tree. The Next.js docs cite exactly this as the justification for loading data in the component that uses it rather than at the top of the route with props passed down. A direct ORM call such as:
db.product.findUnique({
where: { slug },
})
gets none of that behavior just because two components pass it the same slug. To React it is simply an arbitrary async function, and it will run every time it is called until you give it a memoized identity.
Component boundaries describe who owns a piece of UI. They say nothing about who owns a piece of repeated data work. Colocation is not the mistake; assuming colocation implies deduplication is.
Measure before you memoize
Memoization is a response to observed duplication, not to a suspicion. Spotting the following call in four different files:
await getProduct(slug)
is not proof that the database was queried four times. In Next.js that distinction is especially important, since the framework may already be deduplicating identical fetch calls. Recent versions of Next.js also offer development logging for server-side fetch activity, which helps you see what is actually being requested (check the current docs for how to enable it in your version).
If getProduct() sits on an ORM, a database driver or an SDK, instrument that layer instead. For a quick local investigation, even crude timing output is enough to reveal a pattern:
export async function getProduct(slug: string) {
console.time(`product:${slug}`)
const product = await db.product.findUnique({
where: { slug },
})
console.timeEnd(`product:${slug}`)
return product
}
If you see that label printed four times per page load, you have evidence. In production you want stronger signals: database query logs, distributed tracing, APM spans, upstream request counters and request IDs that let you correlate every query with the page view that caused it.
The key is to separate two statements that sound alike:
Function called four times
versus:
Underlying data source hit four times
They are not equivalent. If the operation already executes only once, wrapping it in another layer is not an optimization; it only makes the data layer harder to reason about. Optimize repeated work you have actually observed, not repeated function calls you happened to notice in the code.
Why an ORM-backed function escapes deduplication
Suppose the product loader is as simple as it gets:
export async function getProduct(slug: string) {
return db.product.findUnique({
where: { slug },
})
}
Now imagine it is called from the metadata function, the page, the breadcrumbs and a pricing component during a single request. The function is the same, the argument is the same and the query is the same. Without a memoization boundary, each invocation still runs its own query against the database.
For ORM or direct database access, React cache() supplies the per-request memoization that native fetch gets for free in the React tree. Next.js documents exactly this pattern for direct database queries, including the case where the same record is needed by both generateMetadata and the page.
That understanding also guards against a popular myth. If getProduct() were built entirely on identical fetch calls, wrapping it in cache() purely to remove those duplicates would not achieve much, because Next.js already memoizes them. So the guideline is not to put cache() around every server-side loader. It is to check whether the way you load data is already memoized, and to add a shared identity only where it is missing. That version is much harder to apply blindly.
The fix: one memoized data function
The code change itself is small. Here is the loader before:
export async function getProduct(slug: string) {
return db.product.findUnique({
where: { slug },
})
}
And here it is after wrapping it with cache():
import { cache } from 'react'
import 'server-only'
export const getProduct = cache(async (slug: string) => {
return db.product.findUnique({
where: { slug },
})
})
Two details are worth noticing. The server-only import makes the build fail if this module is ever pulled into client code, which is a sensible guard for anything that talks to your database. And cache() wraps the function once, at module level, so every importer receives the same memoized function. Every consumer keeps calling it exactly as before:
const product = await getProduct(slug)
React stores the result for each argument in its server-side cache. Any later call during that request, through that same wrapper and with an equal argument, gets the stored result back, which is actually the same promise, so concurrent callers await a single query. React discards these memoized results between server requests.
What did not have to change
The valuable part of this fix is everything that stayed the same. The product header still declares its own data requirement. The breadcrumbs gain no new props. Metadata generation and page rendering continue to ask for the product independently, and no UI component is forced into an ownership relationship it does not naturally have. The optimization lives entirely at the data boundary. What you centralized is not where the data is consumed but the identity of the operation that produces it.
Pitfalls with cache()
A few implementation details decide whether the memoization actually works:
- Share one memoized function. Wrapping one loader with
cache()in two different places yields two independent memoized functions, each with its own storage, a behavior React documents explicitly. Define the wrapper once in your data-access module and import it everywhere. - Prefer primitive arguments. React compares arguments by identity, so a slug string hits the cache reliably, while a freshly built object like
{ slug }on each call will miss every time. - Remember the scope.
cache()is meant for server rendering; outside a request, such as in a client component, it does not give you this deduplication.
Why not just fetch everything in the page?
The obvious alternative is to load the product once at the top and pass it down:
export default async function ProductPage({
params,
}: PageProps<'/products/[slug]'>) {
const { slug } = await params
const product = await getProduct(slug)
return (
<>
<Breadcrumbs product={product} />
<ProductHeader product={product} />
<ProductDetails product={product} />
</>
)
}
When the page genuinely owns the whole product object, this is a perfectly good design. The trouble starts when you hoist every data dependency solely to avoid duplicate backend work. Over time, props multiply, intermediate components start forwarding data they never use, and every new child that needs a field forces changes up the chain. Component boundaries end up reflecting optimization mechanics instead of real ownership.
Request memoization changes that trade-off. Current Next.js guidance says identical fetch calls can stay in the components that need them instead of requiring top-level loading and prop drilling, and for direct database access cache() gives a shared server function comparable deduplication semantics. You get both properties at once:
data close to consumer
+
deduplicated underlying work
Removing repeated work should not force unrelated components to share ownership of one data object. Hoisting remains a good choice when the parent naturally owns the data; it just should not be mandatory because the data layer lacks an identity for repeated work.
Request memoization is not persistent caching
Next.js terminology makes this easy to blur, so it is worth being precise. React cache() in this pattern does not turn one visitor's product lookup into a stored answer for future visitors. React clears its memoized server results for every request. Within one request, repeated calls reuse the result:
getProduct("keyboard")
getProduct("keyboard")
getProduct("keyboard")
//reuse the memoized result
A subsequent request starts with an empty cache and performs the lookup again:
New request
getProduct("keyboard")
//perform the lookup again
That is request memoization, and nothing more. Reusing results across requests is a separate architectural decision. In current Next.js, Cache Components provide the use cache directive for caching work beyond a single request, with cacheLife() controlling how long an entry lives and cacheTag() enabling tagged invalidation. The guide to use cache and tag-based revalidation covers that side in depth.
A useful mental model is that the two mechanisms answer different questions:
- Request memoization: should an identical operation run four times while one response is being built?
- Persistent caching: may a later request reuse an answer that was computed earlier?
Only the second brings freshness and invalidation into play. Treat them as two separate decisions and the Next.js caching story becomes far less confusing.
Where the savings actually come from
The product query itself can be perfectly healthy. Suppose telemetry reports four identical database operations per request and, after the change, only one remains. You have saved three lookups per page view, which sounds trivial. Now multiply it by traffic: a route serving thousands of views avoids three times that many queries, and a busy route over a day avoids far more. Minor duplication on a heavily trafficked route can add up to thousands of unnecessary database queries or upstream calls without any individual operation ever looking alarming.
That is also why concrete savings figures only belong in a claim when they come from your own measurements. If your production metrics show a specific number of avoided operations, cite that number; without telemetry, "can save thousands" describes the multiplication effect honestly without inventing a case study.
It shifts how you approach performance work, too. The habitual question is:
Which query takes 800 ms?
Often the more valuable question is:
Why are we paying for this normal query
four times within one request?
A lookup can be individually cheap and collectively wasteful. Performance work is not only about making each operation faster; sometimes it is about deleting operations that never needed to exist, and this matters most when a small inefficiency sits on a hot path.
Choosing the right reuse boundary
Request memoization is comparatively easy to reason about, because React never carries a memoized result over into a future request. Persistent caching needs a broader correctness discussion. With Cache Components, cached work can carry explicit lifetimes and tags for revalidation precisely because reuse across requests raises questions about how long a value stays valid and which events should make it stale.
So the better question is not:
Can we cache this?
but:
Across which boundary is reuse correct?
A rough way to think about the possible boundaries:
- Within one request: safe for almost any read, including per-user data, since nothing outlives the response. This is where
cache()operates. - Across requests, for public data: appropriate for content that is the same for every visitor, provided you define a lifetime and an invalidation path.
- Across requests, for user-specific data: the cache key must include the user identity, or one user can be served another user's result.
- Across requests, for authorization-dependent data: whatever determines access has to be part of the reuse identity, or permission checks are silently bypassed.
Those final two points are not specific to Next.js; they apply to any cache. As soon as the output varies by who is asking or what they may see, the key that decides reuse has to encode that same distinction. An impressive hit rate means nothing if a response can leak to a request that was never entitled to it. The best cache is the one whose reuse rules match the data's correctness rules.
The requests were a boundary problem
Look back at the call that started this:
await getProduct(slug)
Nothing about it was wrong inside generateMetadata(), nor in the page, nor in a nested Server Component. Each consumer genuinely needed the product. The waste appeared only because those sensible calls each crossed the same backend boundary on their own. The fix did not require making any query faster; it required noticing that one answer was being bought several times per render. In code, the whole remedy can be as small as a single wrapper, defined once in the data module and shared by every consumer:
cache(async (...) => ...)
Key takeaways
- Identical native
fetchcalls are already memoized in the React tree by Next.js. ORM, driver and SDK calls are not, andcache()gives them a per-request memoized identity. - Measure first. A function called four times is not the same as a data source hit four times.
- Share one memoized function; separate
cache()wrappers do not share results. - Good component boundaries do not have to be sacrificed. Deduplicate within the request where it helps, and make persistent caching its own deliberate choice, with its own freshness and security rules.
The broader lesson: many worthwhile Next.js performance gains come less from where data is loaded and more from deciding for how long one data access should count as a single operation.