vinext
Documentation menu

Documentation

Caching on Cloudflare

Choose how vinext stores rendered responses and cached data on Cloudflare Workers.

Caching

Vinext supports several caching setups on Cloudflare. Caching is optional: if you do not enable it, your app still works without a shared response or data cache.

What Vinext caches

There are two main kinds of cache:

  • Response / CDN cache: rendered HTML, RSC payloads, and other ISR responses.
  • Data cache: cached fetch calls, unstable_cache, and functions marked with "use cache".

revalidatePath() and revalidateTag() invalidate entries in the configured cache. A route that uses request-specific data such as cookies or headers is not added to the shared response cache. See Which App Router pages are cached for the full rules.

Static files and browser caching are separate from these options. Cloudflare can cache built assets without enabling a vinext cache adapter.

Opting a route out of response caching

If an App Router page or Route Handler must render for every request and should not consult the shared response cache, prefer an explicit route segment config:

export const dynamic = "force-dynamic";

This matches Next.js behavior and lets vinext identify the route at build time, before a configured Response Store or Workers Cache lookup. Use it for an unconditional opt-out instead of relying on request APIs such as cookies() or headers() to make the route dynamic during rendering. A matching public cache policy from next.config still takes precedence when one is deliberately configured.

Options

SetupResponse storageData storageBest forMain trade-off
No persistent cacheIn-memoryIn-memoryDynamic apps and initial migrationsEvery request may need to render and fetch its data
Workers Response Store (recommended)Workers Cache backed by R2Workers Response StoreDurable responses, SWR, cache warming, and one cache system for responses and data

Requires R2, a SQLite Durable Object, and either a separate cache Worker or extra bindings on the app Worker

Workers Cache + data cacheWorkers CacheWorkers KVFast edge responses using Cloudflare's native caches

Cached responses have no durable backing store, and a hit in one region does not guarantee a hit elsewhere

Data cacheWorkers KVWorkers KVA simple persistent cache without Workers CacheRequests still reach the Worker and KV is eventually consistent
Static AssetsWorkers Static AssetsSeparately configuredImmutable sites that should render during the build but still run as a WorkerRead-only until the next deployment; runtime writes and invalidations are ignored

When caching is enabled through vinext init, Workers Response Store is the default choice.

The snippets below extend the generated config; keep your other Worker settings and bindings. existingExports represents your current Worker's exports object, or {} if it has none.

Workers Response Store

Workers Response Store is the most complete and best supported option. Workers Cache serves hot responses, R2 provides a durable backing store, and a SQLite Durable Object tracks metadata and invalidation state. An edge miss can read the stored response from R2 instead of immediately rendering the route again.

It also handles the data cache, so it replaces both workersCacheCdnAdapter() and kvDataAdapter():

import { responseStoreAdapter } from "@vinext/cloudflare/cache/response-store-adapter";

vinext({ cache: responseStoreAdapter() });

Metadata sharding is available as an explicit scaling option:

vinext({ cache: responseStoreAdapter({ shards: 16 }) });

Each cache key remains strongly coordinated by one SQLite Durable Object. Tag/path refreshes and purges fan out across all shards. The option is disabled by default, and changing the count starts a new cache layout for the deployed Worker version.

Service-binding mode

The default service-binding mode keeps the cache service in a separate Worker. vinext init defines both Workers in cloudflare.config.ts using the config helper:

import { createWorkersResponseStoreServiceBindingConfig } from "@vinext/cloudflare/cache/config";

const responseStore = await createWorkersResponseStoreServiceBindingConfig({
  worker: {
    name: "my-app-response-store",
    compatibilityDate: "2026-09-27",
    compatibilityFlags: ["nodejs_compat"],
  },
  bucket: "my-app-response-store-cache-bodies",
});

export const responseStoreServiceBinding = responseStore.serviceBindingWorker;

Merge the application settings into the existing Worker config, preserving your other bindings:

defineWorker({
  // ...existing Worker settings
  ...responseStore.applicationWorker,
  env: {
    // ...existing bindings
    ...responseStore.applicationWorker.env,
  },
});

The helper owns the Response Store's R2 binding, SQLite Durable Object export and binding, cache settings, and application service/version-metadata bindings. It imports bindings and exports from your installed Cloudflare Vite plugin. Set accountId on the top-level defineConfig object if needed, not on a Worker. You can also pass it to the helper and use the returned responseStore.accountId there.

Add the exported Worker to your existing cloudflare() options in vite.config.ts:

import { responseStoreServiceBinding } from "./cloudflare.config";

cloudflare({
  // ...existing plugin options
  auxiliaryWorkers: [{ config: responseStoreServiceBinding }],
});

Build both Workers and deploy the Response Store explicitly. cf deploy creates the named R2 bucket if it does not already exist:

pnpm run build:vinext
pnpm run deploy:response-store
pnpm run deploy:vinext

Init generates deploy:response-store as cf deploy --prebuilt --mode production --worker my-app-response-store using your project's Worker name. create-vinext-app names the other scripts build and deploy. Both Workers build together, but application deployments do not deploy the Response Store. Redeploy it only when its package or config changes.

Self-contained mode

For a single-Worker deployment, select self-contained mode in the adapter:

vinext({ cache: responseStoreAdapter({ mode: "self-contained" }) });

This avoids a second Worker, but the application Worker owns the R2 bucket, Durable Object, and cache-enabled entrypoint, which may not be desired for some applications.

Use the matching config helper. The adapter generates the required Worker exports during the build:

import { createWorkersResponseStoreSelfContainedConfig } from "@vinext/cloudflare/cache/config";

const cache = await createWorkersResponseStoreSelfContainedConfig({
  worker: "my-app",
  bucket: "my-app-response-store-cache-bodies",
});

defineWorker({
  // ...existing Worker settings
  ...cache,
  env: { /* existing bindings, */ ...cache.env },
  exports: { ...existingExports, ...cache.exports },
});

cf deploy creates the named R2 bucket if it does not already exist. No auxiliary Worker or separate Response Store deployment is needed in this mode.

Workers Cache and KV

The older split setup uses Workers Cache for rendered responses and Workers KV for cached data:

import { workersCacheCdnAdapter } from "@vinext/cloudflare/cache/workers-cache-cdn-adapter";
import { kvDataAdapter } from "@vinext/cloudflare/cache/kv-data-adapter";

vinext({
  cache: {
    cdn: workersCacheCdnAdapter(),
    data: kvDataAdapter(),
  },
});

Workers Cache can serve a response without rerunning the render stage. Middleware and request routing still run before vinext selects the cached response entrypoint.

This setup is fast when an entry is present at the edge, but Workers Cache is distributed rather than one globally shared cache. A response cached in one region may still miss in another. Tiered caching can reduce this duplication, but without a durable response store a regional miss or eviction can require another render. The KV data cache is also eventually consistent.

Use createWorkersCacheConfig() to configure the response entrypoints and version metadata, and add the KV binding for the data adapter:

import { bindings } from "@cloudflare/vite-plugin/experimental-config";
import { createWorkersCacheConfig } from "@vinext/cloudflare/cache/config";

const cache = await createWorkersCacheConfig();

defineWorker({
  // ...existing Worker settings
  ...cache,
  env: {
    // ...existing bindings
    ...cache.env,
    VINEXT_KV_CACHE: bindings.kv(),
  },
  exports: { ...existingExports, ...cache.exports },
});

The helper disables caching on the application's default entrypoint and enables it on VinextCachedResponse. The source config owns these policies; vinext does not rewrite them in the Build Output.

KV data cache

You can use Workers KV without Workers Cache:

import { kvDataAdapter } from "@vinext/cloudflare/cache/kv-data-adapter";

vinext({
  cache: {
    data: kvDataAdapter(),
  },
});

Add the matching namespace to the Worker's env in cloudflare.config.ts:

import { bindings } from "@cloudflare/vite-plugin/experimental-config";

env: {
  // ...existing bindings
  VINEXT_KV_CACHE: bindings.kv(),
},

Cloudflare automatically provisions the namespace on deployment. To reuse an existing namespace, pass its ID to bindings.kv({ id: "<existing-namespace-id>" }).

This is the smallest persistent setup. The same data cache can hold ISR responses and nested cached data, but every lookup goes through the application Worker and KV's eventual consistency may briefly expose older values after an update.

Each cached page or data entry in KV expires after the adapter's ttlSeconds (30 days by default, set with kvDataAdapter({ ttlSeconds }) and raised to KV's 60-second minimum if it's between 0 and 60). This includes pages with revalidate = false, which are rendered again on the next request once their entry expires.

Static Assets response cache

For immutable routes, staticAssetsAdapter() packages locally prerendered App Router HTML, RSC payloads, and metadata responses into the configured client assets output. The Worker reads those artifacts through its ASSETS binding:

import { staticAssetsAdapter } from "@vinext/cloudflare/cache/static-assets-adapter";

vinext({
  prerender: true,
  cache: {
    cdn: staticAssetsAdapter(),
  },
});

This is deliberately read-only. Cache writes, revalidatePath(), and revalidateTag() do not change the packaged responses; deploying a new build replaces them. A prerendered page is served for every query string, as Next.js serves its static output. The prerender skips a page that reads searchParams or another dynamic API. Routes that were not prerendered continue to render normally, but this adapter does not persist their responses. Configure a separate cache.data adapter if the app also uses cached data.

Finite revalidate intervals do not refresh packaged responses either. Use a writable response cache if those routes need runtime ISR; Static Assets always serves the build-time snapshot until the next deployment.

The private cache directory must be routed through the Worker so clients cannot fetch raw cache files directly. vinext init --platform=cloudflare --cdn-cache=static-assets configures this automatically. For manual setup, add the following to your existing Worker in cloudflare.config.ts:

import { bindings } from "@cloudflare/vite-plugin/experimental-config";

defineWorker({
  // ...existing Worker settings
  assets: {
    // ...existing asset settings
    runWorkerFirst: ["/_vinext/static-cache/*"],
  },
  env: {
    // ...existing bindings
    ASSETS: bindings.assets(),
  },
});

An existing runWorkerFirst: true also protects these files. Preserve any other Worker-first routes when adding the private directory pattern. Exclusion patterns must not bypass the private directory; init rejects exclusion patterns when configuring this cache.

Existing typed configs require manual Static Assets setup; init stops before modifying the project so it cannot overwrite a custom binding or leave private files exposed.

Cache warming

Cache warming is optional during Cloudflare deploys. With a configured cache adapter, run:

npx @vinext/cloudflare deploy --warm-cache

For an existing deployment serving 100% of traffic from one version, vinext uploads the new Worker, stages it at 0%, discovers routes through the staged Worker with its real bindings, checks readiness, and requests eligible responses to fill the configured cache before promoting the version. Unlike a local prerender and KV bulk upload, rendering happens in the deployed Worker. Static exports (output: "export") still need local prerendered files.

The default warmup makes one fill request per admitted cache identity. Add --warm-cache-certify to require a second, header-only request that proves warmed entries are reusable before promotion. If staging or required warmup fails, the command stops rather than promoting an unverified version; inspect the deployment state before retrying. A first deployment cannot use this staged flow: deploy normally once, then enable warming on subsequent deploys. The command also needs a reachable production route or custom domain and a Worker name for staged version overrides; use --warm-cache-target https://example.com if the target origin cannot be inferred.

Only discoverable, cacheable route identities are warmed; dynamic parameters and query variants are not all enumerated automatically. HTML, RSC, and Pages Router data use separate requests where applicable. For Workers Cache, a warm request does not guarantee a cache hit in every region. See Cloudflare's Workers Caching documentation.

Traffic-aware warming

For apps with many routes, use traffic-aware warming to prioritize pages people actually visit:

npx @vinext/cloudflare deploy --traffic-aware-warm-cache

vinext queries Cloudflare zone analytics, ranks page paths by request count, and resolves them against the app's routes. It selects paths up to the traffic coverage target or route limit, then uses the same staged warming flow and cacheability checks described above. Traffic can supply concrete dynamic paths that were not enumerated by generateStaticParams() or getStaticPaths(); pages that cannot be cached still render on demand.

FlagPurposeDefault
--traffic-aware-coverage <pct>Target percentage of traffic to cover90
--traffic-aware-limit <count>Maximum number of selected routes1000
--traffic-aware-window <hours>Analytics lookback window24
--warm-cache-target <origin>HTTPS origin for analytics zone selection, discovery, probing, and warmingInferred from Cloudflare configuration and deploy output

For example, target 95% of traffic from the last 48 hours, with a maximum of 500 routes:

npx @vinext/cloudflare deploy --traffic-aware-warm-cache \
  --traffic-aware-coverage 95 --traffic-aware-limit 500 --traffic-aware-window 48

This requires a custom domain, a cache adapter that exposes build identity, and CLOUDFLARE_API_TOKEN with Zone > Analytics > Read and Zone > Zone > Read permissions for that zone. See Cloudflare API token permissions. Zone analytics are unavailable for *.workers.dev hosts.

For typed config projects, declare domains: ["example.com"] on the Worker in cloudflare.config.ts. The first domain in the generated Build Output is used for both analytics and staged warming. Wrangler projects use their configured routes for analytics and deployed triggers for the warming origin. Use --warm-cache-target to provide the production HTTPS origin manually; its hostname selects the analytics zone, and the same origin is used for staged discovery, probing, and warming:

npx @vinext/cloudflare deploy --traffic-aware-warm-cache \
  --warm-cache-target https://example.com

Use --traffic-aware-warm-cache on its own to select routes by traffic. Adding --warm-cache uses the full discovered warm plan instead of limiting it to the traffic-selected routes. Traffic-aware warming skips selection when all-route prerendering is configured, and normally skips warming when analytics or a safely stageable existing deployment are unavailable.

Add --warm-cache-certify to require reusable cache hits for every selected entry before promotion. Certification fails if warming is skipped or no entries can be certified. Use --no-promote to leave the warmed version at 0% traffic for verification.

Which option should I choose?

  • Choose no persistent cache while migrating an app or when every response is intentionally dynamic.
  • Choose Workers Response Store (recommended) for the most complete Cloudflare caching setup and durable response storage.
  • Choose Workers Cache + KV when you specifically want the existing native Workers Cache architecture and accept that responses have no backing store.
  • Choose KV only when you want the simplest persistent cache and do not need Workers Cache to serve responses.
  • Choose Static Assets when response content is immutable for the lifetime of a deployment and should be produced during the build.

You can run vinext init --platform=cloudflare to configure these choices. The generated Vite and Cloudflare config files are normal source files and can be adjusted later.

Which App Router pages are cached

vinext stores a rendered App Router page (ISR) when Next.js would treat its route as static or SSG:

  • a route without dynamic segments, or a dynamic route whose generateStaticParams covers its last dynamic segment, is static unless its render uses a dynamic API. Returning [] from generateStaticParams opts every path into on-demand ISR, unless dynamicParams = false;
  • dynamic = "force-dynamic", revalidate = 0 or an edge runtime makes a route dynamic. Otherwise, dynamic = "force-static" or dynamic = "error" makes it static.

Other routes render on every request. Their "use cache" functions and cached fetch calls are still reused. Routes built with cacheComponents follow their own rules, and Differences from Next.js lists the cases where vinext classifies a route differently.

A static page defaults to revalidate = false, as in Next.js, unless a revalidate export, a fetch with a numeric next.revalidate, cacheLife() or a "use cache" function in its render sets a lifetime. A cached fetch without one, such as cache: "force-cache", doesn't shorten the page's lifetime. A revalidate = false page is rendered again only after revalidatePath() or revalidateTag() invalidates it, or once the cache drops it.

Dynamic APIs include cookies(), headers(), connection(), a fetch with cache: "no-store" outside a cache scope, and reading searchParams. Under dynamic = "force-static" they return empty values or don't count, so the page is still stored.

useSearchParams() in a client component doesn't make a page dynamic, and returns empty search params under dynamic = "force-static". Otherwise, in production and outside cacheComponents, a cacheable page's HTML gets the nearest <Suspense> fallback and the browser renders the real value after hydration, as in Next.js. With no <Suspense> boundary above the call, the HTML request fails with Next.js's useSearchParams() should be wrapped in a suspense boundary error, where Next.js fails the build.

Query strings

Next.js keeps one cache entry per static page, whatever the query string, and vinext does the same for App Router pages: /blog/a?ref=x and /blog/a?utm_source=y share the /blog/a entry. A render that reads the query is dynamic and isn't stored, so one visitor's query can't reach the shared entry.

  • KV and in-memory: page keys leave out the query, for App Router and Pages Router pages.
  • Workers Response Store: a miss renders from the full request, and the response is stored under the path without the query.
  • Workers Cache: a hit skips rendering (middleware and routing still run), so the key is chosen before rendering, from the cacheability manifest that a staged deploy writes (--experimental-warm-cdn-cache, see Cache warming). Paths the manifest marks static share one entry across queries, and a request for one that turns dynamic at runtime can fail with a 500, like Next.js's Page changed from static to dynamic at runtime error. Other paths, and every path without the manifest, keep the full query in their key.

With Workers Response Store and Workers Cache, some responses aren't stored under the query-free key, for example interception RSC requests and responses that a next.config headers() rule makes cacheable. Pages Router pages keep the query in their keys on these two backends.

In production, most App page responses that vinext knows will not be stored send Next.js's Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate, unless middleware or a next.config headers() rule sets Cache-Control.

Freshness and revalidation

vinext follows Next.js-style cache semantics:

  • A fresh entry is returned immediately.
  • A stale entry may be returned while stale-while-revalidate refreshes it in the background.
  • An expired entry is not returned and must be regenerated.
  • revalidatePath() invalidates content associated with a path.
  • revalidateTag() invalidates content associated with a cache tag.

With the KV or in-memory cache, when a background regeneration of an App Router page fails, the previous entry is kept and regeneration is retried later, as in Next.js.

The adapter changes where entries live and how they are served, but it should not change the caching API used by application code.