Better I18NBetter I18N

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: STALE for up to 24 hours. No 5xx surfaces to your SDK during short outages.
  • Observability: every response carries X-Cache, X-Cache-Tags, Age, and Last-Modified headers 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:

AxisIncluded 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 GMT

Cache state semantics

X-Cache valueWhat it meansWhen it happens
HITFresh cached response, no origin tripAge < 60s and entry present
MISSOrigin hit, response freshly cachedFirst request, or post-purge, or after freshness window with healthy origin
STALEOrigin unreachable, expired cache served as fallbackOrigin 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 60s

Purge scopes (finest to broadest):

ActionScope purged
Publish / update / delete one entryThat entry (all languages) + the model's list caches
Bulk publish / deleteEach named entry (all languages) + the model's list caches (one atomic fan-out)
Add / edit / remove a fieldEvery list and entry cache under the model
Rename / delete a modelEvery cache under the model
Project-wide changeModels 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: STALE with Age reflecting 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:

  1. 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.
  2. You can detect degraded mode. Monitor X-Cache: STALE rates — non-zero means origin is unhealthy.
  3. Staleness is bounded and auditable. Age header is never higher than freshness window + stale budget. You can alert on Age > 3600 if 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=120
  • s-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:

  1. Subscribe to Content CMS webhook events (entry.published, entry.updated).
  2. On receipt, trigger your framework's on-demand revalidation:
    • Next.js: revalidateTag() or revalidatePath()
    • Cloudflare / Fastly: surrogate-key purge
    • Vercel: fetch('/api/revalidate?tag=...', { method: 'POST' })

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:

HeaderMeaningEmitted on
X-Cache: HITFresh cached response from Content API's KVHIT
X-Cache: MISSResponse came from origin (DB), now cachedMISS
X-Cache: STALEOrigin unreachable, serving last cached entrySTALE
X-Cache-TagsOrdered, comma-delimited scope labels (see below)HIT, MISS, STALE
AgeSeconds since the cached entry was captured from originHIT, STALE
Last-ModifiedRFC 1123 timestamp of when origin produced the bodyHIT, STALE
Cache-ControlDownstream 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:published

Tags 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-Tags as 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:

  1. Check X-Cache + Age on the response.
    • HIT with Age < 60 → Content API cache, auto-refreshes within 60s. Wait, then re-check.
    • HIT with Age >= 60 → shouldn't happen (bug — report). Fresh window exceeded.
    • STALE → origin is degraded. Check https://status.better-i18n.com.
    • MISS → response came from origin. If still stale, the data in DB is stale (or your purge didn't fire).
  2. Check your framework's cache headers. If Next.js is serving from its fetch cache, look at next: { revalidate } config.
  3. Check your CDN (Vercel / Cloudflare edge). If s-maxage propagated, 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.

On this page