Multi-Layer Caching with Fetch and React Primitives
Request memoization, tagged server caching, and explicit lifetimes — reusing shared data at every scope while per-shopper content stays fresh.
View the live demo →Concept: layers
Reuse work at every scope, not just one
Rebuilding the same UI and refetching the same data on every request wastes work. Next.js answers with several caches, each reusing results over a different span: inside one server render, across server requests, and across navigations in the browser.
Request memoization dedupes identical reads during a single render. The server-side cache keeps results across requests. The Router Cache holds visited route segments in the browser so back-and-forth navigation doesn't round-trip. Knowing which layer owns which lifetime is what makes the behavior predictable.
Like a kitchen: prep the shared ingredients once for everyone, never chop the same onion twice for one order, and let regulars remember the menu.
Concept: request memoization
Ask as often as you like, run once per render
Identical GET fetches are deduplicated for the length of one server render. For anything that isn't fetch — a GraphQL client, a database call — React's cache() gives the same request-scoped reuse:
import { cache } from "react";
export const getShopperProfile = cache(async (shopperId: string) => {
return runQuery(SHOPPER_QUERY, { id: shopperId });
});Components can then each call getShopperProfile without prop-drilling the result. Memoization ends with the request, so it dedupes work but never makes data stale.
Concept: use cache
Explicit server caching with a lifetime and a label
With cacheComponents on (see Partial Prerendering), data is dynamic by default and caching is opt-in through 'use cache'. Without it, fetch(url, { cache: 'force-cache' }) opts into the older Data Cache instead. Inside a cached function, cacheLife() sets how long the entry lives and cacheTag() labels it for targeted invalidation:
import { cacheLife, cacheTag } from "next/cache";
async function getCachedProducts() {
"use cache";
cacheLife("days");
cacheTag("catalog");
return getProducts();
}cacheLife() alone enables nothing — the directive does the caching. And put it as close to the data as possible: a directive on a page or layout over-caches anything user-specific beneath it. One boundary is enough, too; a file-level directive plus a function-level one is redundant.
Concept: invalidation
Expire by tag after a mutation
Tags let a mutation expire exactly the entries it affected. In a Server Action, updateTag expires the entry immediately so the next render sees the write. In a Route Handler, revalidateTag takes a profile as its second argument — the one-argument form is deprecated:
"use server";
import { updateTag } from "next/cache";
export async function restockCatalogAction() {
await restockCatalog();
updateTag("catalog");
}
// In a Route Handler instead:
// revalidateTag("catalog", "max");Accuracy note: 'use cache' entries live in server memory by default, so they don't persist across serverless instances the way a durable store would. Reach for 'use cache: remote' when entries must be shared and durable.
Concept: narrow boundaries
Cache the shared part, stream the personal part
A store page has both kinds of content. The catalog is identical for every visitor, so it's cached. Recommendations depend on who is looking, so they stay uncached behind <Suspense>:
<ProductCatalog />
<Suspense fallback={<LoadingRecommendations />}>
<PersonalizedRecommendations searchParams={searchParams} />
</Suspense>Reading a runtime value like searchParams happens inside the boundary, so the page itself stays free of request data and the cached catalog still joins the static shell.
Implementation example
The catalog demo's structure
The book's Prisma queries become GraphQL queries against a small in-process graphql-yoga schema, the same data-access style this site uses everywhere else.
app/patterns/multi-layer-caching/
├── data.ts ← yoga schema, queries, cache() memoization
└── products/
├── layout.tsx ← static shell: page chrome, no data access
├── page.tsx ← cached catalog + Suspense'd recommendations
└── actions.ts ← Server Action calling updateTag("catalog")
components/patterns/multi-layer-caching/
├── product-catalog.tsx ('use cache' + cacheLife + cacheTag)
├── catalog-controls.tsx (restock form)
├── personalized-recommendations.tsx (uncached, streams)
├── shopper-badge.tsx (shares a memoized lookup)
└── loading-recommendations.tsx (Suspense fallback)There are three important pieces here.
The catalog is cached and tagged. Its revision number only changes after you press restock, which expires the catalog tag.
Recommendations run per request. Switch shoppers and only this section re-runs, behind its own boundary.
The shopper lookup is memoized. Two components ask for the profile; the footer shows it was looked up once.
Open the console on the live demo and reload: the catalog query logs rarely, the recommendations query every time.