Skip to content

Caching

Content passes through up to four caches on its way to a visitor. Knowing which ones you control tells you where a stale page can come from.

Cache Where What you do
The API’s response cache Inside the Delivery API Nothing. It clears itself when content changes.
The edge In front of the API Send env on /sites, /locales and /sitemap
The SDK’s in-memory cache In each client Size it, bypass it, or clear it
The SDK’s persisted cache The visitor’s browser Opt in with cache.persist

Your framework’s own render cache and your CDN sit on top of these.

The Delivery API serves repeated identical requests from its own in-memory cache, keyed on everything that can change the answer, including locale, depth and the context bag. Publishing, unpublishing, or editing anything delivery reads when it serves a request (audiences, categories, streams, Adapter rules, host mappings, the locale tree) clears it for the next request. You don’t need a cache of your own in front of the API to keep it fast.

Three endpoints describe a whole site and are identical for every visitor, so they can be answered by the edge without reaching the API: /sites, /locales and /sitemap. A request is cacheable when:

  • it carries env=<deliveryEnvironmentId>, the value /sites returns, and that id matches the key’s delivery environment (a mismatch answers 400 environment_mismatch);
  • the key has no IP allow-list (an origin allow-list is fine);
  • for /sitemap, it also names the site with site=.

A cacheable response carries:

Cache-Control: public, max-age=300, stale-while-revalidate=600
ETag: "sha256-…"
Access-Control-Allow-Origin: *

and a request sending that ETag back in If-None-Match gets 304 Not Modified. Anything else answers Cache-Control: private, no-store. Changes to sites, hosts, locales and pages also purge these entries, but plan for a change to take up to about 15 minutes to reach every visitor: the five-minute max-age plus ten minutes of stale-while-revalidate.

Every other endpoint is served fresh by the API, and /nodes says so explicitly with private, no-store.

A proxy that compresses a response rewrites a strong ETag into a weak one, W/"…", on its way to the browser. Send back whichever form you received. If you serve your own cacheable responses, such as sitemap files, compare If-None-Match weakly: strip a leading W/ from both sides before comparing, or a 304 never happens in production even though every local test passes. The etagMatches helper below does that.

Each createContentClient client keeps recent results in memory: 50 by default, least recently used dropped first. Keys include everything that changes the answer, the context bag among them, so two visitors with different bags never share an entry. Concurrent requests for the same thing share one network request.

  • cache: { maxEntries } resizes it, and cache: false turns it off.
  • resolveLocation(path, { fresh: true }) bypasses it for one read and stores the new result.
  • invalidate() drops everything the client holds, including persisted entries and the site and locale it had chosen.

It has no expiry. In a browser tab that rarely matters, because a tab doesn’t live long. On a server it matters a great deal; see On a server.

cache: { persist: true } also keeps successful results in the browser’s localStorage. On a repeat visit, <Experience> and useExperience paint the stored page on the first render, with no loading state, while exactly the same requests run behind it and replace it. stale is true on useExperience and useNavigation until they do, if you want to show that the page is updating. peekLocation and peekReference read the stored entries directly, without a request.

caching.ts
// Caching choices for the high-level client in a browser, and a helper for serving your own
// cacheable responses.
import { createContentClient } from '@ebitex/content-sdk'
declare const BROWSER_SAFE_KEY: string
// The `deliveryEnvironmentId` that `listSites()` returns. It is fixed for a given delivery key, so
// a build-time constant is fine.
declare const DELIVERY_ENVIRONMENT_ID: string
export const content = createContentClient({
apiKey: BROWSER_SAFE_KEY,
// Sent as `env=` from the very first `/sites` and `/locales` request, which is what lets the edge
// answer a visitor's first request. Without it, the client learns the value from its first
// `/sites` response and sends it from then on.
env: DELIVERY_ENVIRONMENT_ID,
cache: {
// Results kept in memory for the life of the page. The least recently used are dropped first.
maxEntries: 50,
// Also keep successful pages in localStorage. A repeat visit paints the stored page at once,
// while the same requests run behind it and replace it.
persist: { maxAgeMs: 24 * 60 * 60 * 1000, maxEntries: 20 },
},
})
// Skip the cache for one read and replace what it held, for a reload control.
export function reload(path: string) {
return content.resolveLocation(path, { fresh: true })
}
// Drop everything this client holds, persisted entries included.
export function forgetEverything(): void {
content.invalidate()
}
/**
* Compares an `If-None-Match` request header with your response's `ETag` the way HTTP specifies
* for a GET: weakly. A proxy that compresses a response rewrites a strong `ETag` to a weak one
* (`W/"…"`) on its way to the browser, so the browser sends the weak form back, and an exact string
* comparison never matches.
*/
export function etagMatches(ifNoneMatch: string | null, etag: string): boolean {
if (!ifNoneMatch) return false
if (ifNoneMatch.trim() === '*') return true
const bare = (value: string) => (value.startsWith('W/') ? value.slice(2) : value)
return ifNoneMatch.split(',').some((candidate) => bare(candidate.trim()) === bare(etag))
}
// A conditional response for something you serve yourself, such as a sitemap document.
export function conditionalResponse(request: Request, body: string, etag: string, contentType: string): Response {
const headers = { ETag: etag, 'Content-Type': contentType, 'Cache-Control': 'public, max-age=3600' }
if (etagMatches(request.headers.get('If-None-Match'), etag)) {
return new Response(null, { status: 304, headers })
}
return new Response(body, { status: 200, headers })
}
  • It never replaces a request, only a loading state. Every request still runs on every visit.
  • Only pages and references that resolved are stored. A redirect or a not-found result is never replayed from storage.
  • Entries older than maxAgeMs (24 hours by default) are ignored, and at most maxEntries (20 by default) are kept.
  • It does nothing during live preview, so an editor never sees a stored published page under a draft, and a draft is never stored. It also does nothing outside a browser.
  • Storage problems are silent. A private window, blocked storage or a full quota just behaves as if nothing was stored.

The env option in the example makes a visitor’s first /sites and /locales requests cacheable at the edge. Without it, the client learns the value from its first /sites response and sends it on every request after that.

A server wants one client for the life of the process, because the client carries the cache every request shares. A module-level constant may not give you one: some frameworks evaluate the same module more than once in one process (a page and a route handler, for example), and each copy gets its own client and its own cache. sharedContentClient pins one instance globally, so every copy of the module gets the same one:

server-client.ts
/**
* The server-side client recipe: one client per process, not per request.
*
* A module-level `const` does not reliably give you one: Next.js evaluates a server component's
* module graph and a route handler's module graph separately, so the `const` is instantiated
* **twice** and each copy carries its own cache.
*
* That is a freshness defect rather than a safety one — `ctx` is part of every cache key, so two
* caches cannot cross-serve personalized content, they simply both exist. But the cache has no TTL,
* and the documented answer to that is `invalidate()` on a signal, whose natural home is a route
* handler. So the remedy lands on a cache no page is using, and the route reports success.
*/
import { createContentClient, sharedContentClient, type ContentClient } from '@ebitex/content-sdk'
// This example is server-side code, so `process.env` is the realistic form. It is declared here so
// the example typechecks without `@types/node`.
declare const process: { env: Record<string, string | undefined> }
/**
* `lib/content.ts` — imported by pages and by route handlers alike, and one instance either way.
*
* The factory returns `null` when the site is not configured, and that `null` is remembered: an
* absent environment variable is an answer, not a miss.
*/
export const content: ContentClient | null = sharedContentClient(() =>
process.env.CONTENT_DELIVERY_KEY
? createContentClient({
apiKey: process.env.CONTENT_DELIVERY_KEY,
site: process.env.CONTENT_SITE_ID,
// Per-request values never belong here — see `resolvePage` below.
})
: null,
)
/**
* A page render. The context bag is passed **per call** rather than configured on the client:
* `context` also accepts a zero-argument supplier, which on one shared client has no way to know
* whose request it is.
*/
export async function resolvePage(path: string, buyerType: string | undefined) {
if (!content) throw new Error('CONTENT_DELIVERY_KEY is not configured')
return content.resolveLocation(path, { context: buyerType ? { buyerType } : {} })
}
/**
* A revalidation webhook. **Both caches have to be cleared**, and clearing either one alone looks
* like it works:
*
* - clear only the SDK's, and the framework may never ask it for anything, so the page re-serves
* its own cached render;
* - clear only the framework's, and it re-renders from content the SDK is still holding.
*
* `revalidate` here stands for the framework's own cache-clearing call — `revalidatePath` in Next,
* whatever the equivalent is elsewhere. The SDK knows nothing about it, which is the point: they
* are two caches, unaware of each other.
*/
export async function handleRevalidate(revalidate: () => void): Promise<{ revalidated: boolean }> {
content?.invalidate()
revalidate()
return { revalidated: true }
}

Because the in-memory cache has no expiry, a long-running server keeps serving what it first read until something clears it. Call invalidate() on whatever signal you have that content changed: a route you call after publishing, a deploy hook, or a timer. Or turn the cache off with cache: false and let the API’s own cache do the work.

Clear both caches. Your framework has its own render cache, and it and the SDK know nothing about each other. Clear only the SDK’s and the framework may keep serving its rendered page; clear only the framework’s and it re-renders from content the SDK still holds.

Sharing one client between visitors is safe for personalized content, because the context bag is part of every cache key. Pass each request’s bag with the call rather than configuring it on the client; see Personalization.