Response Caching
Two independent caching layers — Cloudflare Workers Cache for public HTTP responses (with a hard credential bypass) and OpenNext's R2/D1 incremental cache for Next.js ISR/data.
Response Caching
Valcraven ships two independent caching layers. Understand which is which before you touch either — they solve different problems.
| Layer | What it caches | Where it's configured |
|---|---|---|
| Cloudflare Workers Cache | Whole HTTP responses, in front of the Worker | wrangler.toml [cache] + lib/cache-policy.ts (applied in worker.js) |
| OpenNext incremental cache | Next.js's own ISR pages + fetch data cache + revalidateTag/revalidatePath | open-next.config.ts (R2 + D1 bindings) |
Workers Cache is about caching the finished HTTP response; the incremental cache is about how Next.js renders pages. They are orthogonal.
1. Workers Cache (public responses)
Cloudflare Workers Cache is a tiered cache that sits in front of the Worker's
fetch entrypoint. Two facts drive the entire design:
- On a cache HIT the Worker does not run — the cached response is served directly.
- Caching is decided entirely by the response
Cache-Control. Cloudflare auto-bypasses theAuthorizationrequest header andSet-Cookieresponses, but it does not bypass theCookierequest header.
Because Valcraven authenticates with a Better Auth session cookie, a naive "just
enable caching" would risk serving one user's rendered page to another. All of the
safety logic therefore lives in one small, auditable file — lib/cache-policy.ts —
which worker.js calls on the way out of every request.
The load-bearing invariant
applyCachePolicy() runs a set of guards. The first is the one that makes caching
safe:
GUARD 1: any request carrying a credential gets
Cache-Control: private, no-store.
"Credential" here means a Better Auth session cookie, an x-api-key header, or an
Authorization header (requestCarriesCredentials()). The Worker only runs on a
cache MISS — the single place an entry is ever written — so this guarantees
every response that reaches the cache store was generated for an anonymous
request, which contains no per-user data. A HIT served to anyone therefore
cannot leak another user's content, even under heuristic caching.
On top of that guard:
- Only an explicit allowlist of anonymous, auth-invariant public paths (
/,/blog,/changelog,/docs,/privacy-policy,/terms, plus their sub-paths) is markedpublic, s-maxage=300, stale-while-revalidate=86400with aCache-Tag. - A response that sets a cookie is never upgraded to
public. - A header-less response defaults to
no-store, so nothing is cached by accident.
Making a new route cacheable
Add its path to the allowlist in lib/cache-policy.ts (PUBLIC_EXACT /
PUBLIC_PREFIXES) — but only if its rendered output is identical for every
user (no cookies() or session read in the page). If in doubt, don't.
The security boundary is
requestHasSessionCookie. If you ever change Better Auth'sadvanced.cookiePrefix, update theSESSION_COOKIE_REregex inlib/cache-policy.tsto match, or authenticated responses could start being cached.
Purging
Cached public pages carry Cache-Tags — every page gets page plus a section tag
(marketing, blog, changelog, or docs). Purge a section after a content
change or on deploy via the CRON_SECRET-authed internal route, which calls
purgeCache() in lib/cache-purge.ts → ctx.cache.purge():
curl -X POST https://your-app.example.com/api/internal/cache-purge \
-H "Authorization: Bearer $CRON_SECRET" \
-d '{"tags":["blog"]}' # or {"everything": true}Purging is best-effort and never throws — the database is the source of truth, so a stale entry self-heals on the next TTL even if a purge is missed.
Turning it off, and cost
Set [cache] enabled = false in wrangler.toml to disable the whole layer.
Cost note: enabling Workers Cache bills static-asset requests and worker-to-worker (service binding) calls at standard request rates, and cache MISS/BYPASS still bill CPU. This is called out in
wrangler.toml.
Workers Cache is gated on a current compatibility_date, so wrangler.toml is
pinned to a recent date (2026-07-06) to enable it.
2. OpenNext incremental cache (Next ISR/data)
open-next.config.ts backs Next.js's own cache with durable storage instead of
the default in-memory cache (which lost everything on cold start):
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
import d1NextTagCache from "@opennextjs/cloudflare/overrides/tag-cache/d1-next-tag-cache";
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
tagCache: d1NextTagCache,
queue: "direct",
});incrementalCache: R2 — ISR pages and the fetch data cache persist to the storage bucket under theincremental-cache/key prefix (isolated from user uploads). Binding:NEXT_INC_CACHE_R2_BUCKET.tagCache: D1 — backsrevalidateTag()/revalidatePath(). Binding:NEXT_TAG_CACHE_D1(the same D1 instance as your app;opennextjs-cloudflare deployseeds its own tag table).queue: "direct" — revalidation runs inline, no Durable Object queue. This is the simplest correct default for the template.
Both bindings point at the app's existing R2 bucket and D1 database — they're
declared in wrangler.toml, so there's nothing extra to provision.
Related
- Deploy to Cloudflare Workers — where these bindings live
- The Cloudflare Workers architecture — how
worker.jswraps OpenNext - Feature Flags — another Cloudflare binding-based feature
- The dev wiki page at /admin/docs/caching goes deeper on the guards.
Developer API Keys
Optional per-user API keys for programmatic access — off by default, SHA-256 at rest, reveal-once, and how to enable and opt a route in.
Realtime
A generic per-channel live-event layer — publish() on the server, useRealtime() on the client, gated by a single deny-by-default authorization seam and fanned out by a Durable Object.