Caching & Freshness
How the Content API caches responses, how purge works after a publish, and how to configure caching in your framework
The Content API serves cached responses from Cloudflare KV for low latency. This page explains what's cached, how invalidation works after you publish changes in the dashboard, and how to tune caching in your framework.
TL;DR
- Cache layer: Cloudflare KV, globally consistent, 60 second freshness window.
- Purge trigger: every mutation in the dashboard (publish, update, delete, field change) fires a targeted cache purge within seconds.
- After purge: next request is a cache MISS, reads fresh data, re-caches. Maximum staleness before auto-refresh: 60 seconds (the TTL failsafe).
- Resilience: if the origin database is unreachable past the freshness window, the Content API serves the last cached response with
X-Cache: STALEfor up to 24 hours. No 5xx surfaces to your SDK during short outages. - Observability: every response carries
X-Cache,X-Cache-Tags,Age, andLast-Modifiedheaders so you can trace what you got and when. - Your app's cache: if you put the Content API behind your own cache (Next.js ISR, Vercel edge, a browser
fetch), that layer has its own TTL and is NOT purged when you publish. See Framework Configuration.
What Gets Cached
Only GET requests to /v1/content/* with status 200. The cache key encodes:
| Axis | Included in key? |
|---|---|
| org slug, project slug, model slug | ✓ |
language, status | ✓ |
page, limit, sort, order, fields, expand | ✓ (hashed) |
search=…, filter[x]=… | ✗ — bypasses cache entirely |
High-cardinality parameters (search, filter[*]) skip the cache because unique values almost never repeat before the TTL expires — caching them would burn write quota without serving reads.
Response header X-Cache: HIT, MISS, or STALE tells you which path served the request. Inspect it during development:
curl -I -H "x-api-key: bi-..." \
"https://content.better-i18n.com/v1/content/acme/web/models/blog-posts/entries"
# → x-cache: HIT
# → x-cache-tags: org:acme,project:web,model:blog-posts
# → age: 12
# → last-modified: Wed, 16 Apr 2026 18:41:03 GMTCache state semantics
X-Cache value | What it means | When it happens |
|---|---|---|
HIT | Fresh cached response, no origin trip | Age < 60s and entry present |
MISS | Origin hit, response freshly cached | First request, or post-purge, or after freshness window with healthy origin |
STALE | Origin unreachable, expired cache served as fallback | Origin error past freshness window with cached entry still in 24h retention |
STALE is a resilience signal, not a routine state. Seeing it in production means origin had a blip — the body is up to 24 hours old but your app stays available instead of receiving a 5xx. If STALE rates climb above zero, check the Content API status.
Publish → Purge → Fresh Data
You publish in the dashboard
→ Platform API fires POST /purge to the Content API
→ Content API deletes every cache key under the affected scope
→ Next SDK request: MISS → DB read → fresh data cached for 60sPurge scopes (finest to broadest):
| Action | Scope purged |
|---|---|
| Publish / update / delete one entry | That entry (all languages) + the model's list caches |
| Bulk publish / delete | Each named entry (all languages) + the model's list caches (one atomic fan-out) |
| Add / edit / remove a field | Every list and entry cache under the model |
| Rename / delete a model | Every cache under the model |
| Project-wide change | Models list only |
Purge is best-effort: it fires as fire-and-forget, and if it fails for any reason (network blip, Content API deploy mid-flight), the 60-second freshness window guarantees eventual consistency within one minute. You should never see stale data for more than 60 seconds after a publish — if you do, the most likely culprit is your own framework's cache layer (see below).
Stale-if-error (resilience fallback)
The Content API keeps every cached entry for 24 hours past its freshness window as a fallback pool for origin outages. The window splits into two states:
0s ──────────── 60s ──────────────────────────────── 24h 60s
│ Fresh (HIT) │ Stale (revalidate — MISS on success, STALE on error)- Inside 60s: entry is fresh. Served directly as
X-Cache: HIT. - Past 60s, origin OK: origin is re-hit; cache rewritten with new body; response returned as
X-Cache: MISS. You see the new data as if it were the first request. - Past 60s, origin fails (throw or 5xx): expired entry is served as
X-Cache: STALEwithAgereflecting real age. No error propagates to your SDK. - Past 24h 60s: entry is evicted from KV. Origin error now surfaces as a 5xx to your SDK.
You get three practical guarantees from this layer:
- Short outages are invisible. Database blips, region failovers, deploys — none surface as 5xx to your app as long as the affected content was requested at least once in the prior 24 hours.
- You can detect degraded mode. Monitor
X-Cache: STALErates — non-zero means origin is unhealthy. - Staleness is bounded and auditable.
Ageheader is never higher than freshness window + stale budget. You can alert onAge > 3600if freshness matters for your use case.
Stale-if-error is automatic — nothing to configure on your end.
Response Cache-Control
Every successful GET response carries:
Cache-Control: public, s-maxage=60, stale-while-revalidate=120s-maxage=60— downstream CDNs (Cloudflare, Vercel, your edge) may cache for 60 seconds.stale-while-revalidate=120— for another 2 minutes after expiry, a downstream may serve the stale copy while fetching fresh in the background.
After you publish, your downstream CDN keeps serving its cached copy until s-maxage expires or until you manually purge that layer. The Content API has no way to reach into your Vercel/Cloudflare cache. Plan your revalidation windows accordingly.
Framework Configuration
Next.js (App Router)
Two patterns, pick based on your freshness requirements.
Fresh within 60s (recommended for most sites):
const res = await fetch(
`${API}/v1/content/${ORG}/${PROJECT}/models/blog-posts/entries`,
{
headers: { "x-api-key": process.env.BETTER_I18N_API_KEY! },
next: { revalidate: 60 },
},
);revalidate: 60 matches the Content API's TTL. After you publish, Next.js will fetch fresh data within 60 seconds on the next request.
Instant freshness after publish (e.g. preview mode):
const res = await fetch(url, {
headers: { "x-api-key": process.env.BETTER_I18N_API_KEY! },
cache: "no-store",
});no-store disables Next.js's fetch cache entirely — every request hits the Content API, which still serves from its KV cache. You pay an extra round-trip but skip any Next.js revalidation delay.
What NOT to do:
// ❌ Long revalidate without manual invalidation = stale content for hours
const res = await fetch(url, { next: { revalidate: 3600 } });If you set revalidate longer than 60 seconds, publishes in the dashboard won't surface until Next.js revalidates. If you must use a long window, pair it with On-demand revalidation triggered by a Content webhook.
Remix / Hydrogen
export async function loader({ context }: LoaderArgs) {
const res = await fetch(url, {
headers: {
"x-api-key": context.env.BETTER_I18N_API_KEY,
"Cache-Control": "max-age=60",
},
});
return json(await res.json(), {
headers: {
"Cache-Control": "public, max-age=60, s-maxage=60, stale-while-revalidate=120",
},
});
}Keep your s-maxage aligned with the Content API's (60s). Longer values mean stale content after publish.
Raw fetch in the browser
The browser honors Cache-Control: public, s-maxage=60 for CDN caching but largely ignores it for its own cache. Users will generally get fresh responses each time unless you opt into cache: "force-cache".
Instant Invalidation: Webhooks
If 60 seconds is too long and you need sub-second freshness:
- Subscribe to Content CMS webhook events (
entry.published,entry.updated). - On receipt, trigger your framework's on-demand revalidation:
- Next.js:
revalidateTag()orrevalidatePath() - Cloudflare / Fastly: surrogate-key purge
- Vercel:
fetch('/api/revalidate?tag=...', { method: 'POST' })
- Next.js:
This combines the Content API's 60-second auto-refresh floor with your own revalidation on webhook delivery — giving you best-effort instant freshness without giving up caching.
Observability
Every response carries headers that identify which cache layer served it and how old the payload is:
| Header | Meaning | Emitted on |
|---|---|---|
X-Cache: HIT | Fresh cached response from Content API's KV | HIT |
X-Cache: MISS | Response came from origin (DB), now cached | MISS |
X-Cache: STALE | Origin unreachable, serving last cached entry | STALE |
X-Cache-Tags | Ordered, comma-delimited scope labels (see below) | HIT, MISS, STALE |
Age | Seconds since the cached entry was captured from origin | HIT, STALE |
Last-Modified | RFC 1123 timestamp of when origin produced the body | HIT, STALE |
Cache-Control | Downstream cache directives (see above) | All |
Cache tags
X-Cache-Tags encodes the exact scope of the cached entry. Format:
X-Cache-Tags: org:acme,project:web,model:blog-posts,entry:hello-world,lang:tr,status:publishedTags are ordered broadest-first (org → project → model → entry → language → status). Default values (no language/status) are suppressed to keep the header compact.
Use tags for:
- Debugging: "Which scope was this response keyed under?" — read the tag list.
- Surrogate-key CDNs: If you front the Content API with a Fastly, Bunny, Netlify, or Cloudflare Enterprise cache, you can propagate
X-Cache-Tagsas surrogate keys and relay our purge events to that layer. Webhook receives the invalidated tags; your frontend purges them. - Custom invalidation logic: Tags give you a stable contract for "all caches under this entry" without parsing URLs.
Diagnosing stale responses
If you suspect a stale response after publishing, walk the layers in order:
- Check
X-Cache+Ageon the response.HITwithAge < 60→ Content API cache, auto-refreshes within 60s. Wait, then re-check.HITwithAge >= 60→ shouldn't happen (bug — report). Fresh window exceeded.STALE→ origin is degraded. Checkhttps://status.better-i18n.com.MISS→ response came from origin. If still stale, the data in DB is stale (or your purge didn't fire).
- Check your framework's cache headers. If Next.js is serving from its fetch cache, look at
next: { revalidate }config. - Check your CDN (Vercel / Cloudflare edge). If
s-maxagepropagated, that layer is holding it.
Most "stale after publish" reports trace back to a long revalidate in the SDK caller or a downstream CDN — not the Content API itself.