Fixing Next.js ISR Cache on Cloudflare
How daily GitHub-star revalidation exposed a read-only OpenNext cache, and why working ISR required R2 and a Durable Object queue.
I wanted the GitHub star count in TentUI's header to refresh daily. The implementation was small, but it changed the caching behavior of every route that used the shared layout.
The production logs later showed requests failing as cached pages became stale. The application was trying to revalidate them through an OpenNext configuration that could read build-time assets but could not write regenerated entries or coordinate the work.
The fix was not to remove revalidation. It was to give Incremental Static Regeneration (ISR) the writable cache and queue that it needs on Cloudflare: R2 for cache entries and an OpenNext Durable Object queue for revalidation.
This article covers the production evidence, the internal failure chain, the corrected configuration, and how I tested the stale-to-fresh transition without browser automation. The configuration changes described here were verified locally. They had not been deployed when I wrote this, so production verification was still pending.
The feature that introduced revalidation
The shared GitHub navigation item loads the repository metadata and caches the star count for one day:
const getStargazerCount = unstable_cache(
async () => {
try {
const response = await fetch(
`https://api.github.com/repos/${GITHUB_REPOSITORY}`,
{
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${env.GITHUB_API_TOKEN}`,
"X-GitHub-Api-Version": "2026-03-10",
},
}
);
if (!response.ok) return 0;
const json = (await response.json()) as {
stargazers_count?: number;
};
return Number(json?.stargazers_count) || 0;
} catch {
return 0;
}
},
["github-stargazer-count"],
{ revalidate: 86400 }
);The Next.js unstable_cache API defines revalidate in seconds. It does not run a scheduled job every 86,400 seconds. The cached entry first becomes eligible for revalidation, then a qualifying request triggers the work.
Because this component is in a shared header, its one-day cache lifetime affected routes rendered through that layout. The production build listed /, /blocks, /components, component detail pages, company pages, and other routes with Revalidate 1d. Some route files declared revalidate = false, but the cached dependency in the shared tree still introduced timed revalidation into the built output.
The build output is direct evidence of the route lifetimes. Identifying this shared cache as the timed-staleness trigger is a code-based inference: the logs show stale-page failures but do not name the cached function that made those pages stale.
That was valid application behavior. The requirement was to keep the automatic daily refresh, not to make the star count permanent.
The incompatible cache configuration
TentUI was using OpenNext's Workers Static Assets incremental cache with cache interception:
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import staticAssetsIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/static-assets-incremental-cache";
export default defineCloudflareConfig({
incrementalCache: staticAssetsIncrementalCache,
enableCacheInterception: true,
});This configuration had previously avoided prerender misses and NoFallbackError behavior for fully static content. Cache interception could answer a request from the prerendered cache without loading the full Next.js server.
The important constraint was hidden in the cache backend. OpenNext documents the Workers Static Assets incremental cache as read-only and intended for applications that do not revalidate. Its installed set() and delete() implementations only log failures.
There was also no revalidation queue in open-next.config.ts. OpenNext therefore selected its dummy queue. The installed dummy implementation throws FatalError("Dummy queue is not implemented") whenever send() is called.
The site had combined three settings that could not work together once an entry became stale:
- a one-day revalidation interval;
- a read-only incremental cache;
- no real revalidation queue.
Most pages still looked static. The incompatible path was only exercised when a request found stale content and OpenNext attempted revalidation.
What production showed
I analyzed two Cloudflare log exports and grouped records by Cloudflare request ID. That correlation was necessary because one request could emit an invocation record, a root error, and one or more stack traces.
The main export contained exactly 100 raw log records. A second export contained 40 representative error-signature records. Grouping the raw records produced 41 request-level incidents and 32 confirmed HTTP 500 responses.
The observed window ran from 2026-07-23T23:30:47.140Z to 2026-07-24T03:42:48.500Z, about four hours and twelve minutes inside the requested 24-hour period. Because the main export stopped at exactly 100 records, it may have been capped. These are observed counts and may be lower than the full production total.
Of the 32 confirmed 500 responses:
- 30 came from the OpenNext routing, cache, and queue failure: 93.75% of the confirmed failures;
- two adjacent
POST /requests were attributed to a separate environment initialization failure.
The confirmed route distribution was:
| Route or group | Confirmed 500s |
|---|---|
GET /blocks | 5 |
GET /components | 4 |
GET /privacy | 2 |
GET /terms | 2 |
POST / | 2 |
| 17 block, component, copyright, and license routes | 17 |
Nine additional homepage requests logged read-only cache writes and failed stale-page revalidation. Their invocation response records were absent, so I did not count them as confirmed 500s.
Seven request IDs contained an explicit missing GITHUB_API_TOKEN validation message. Six overlapped the homepage cache incidents. The remaining explicit signal belonged to one of the two POST / failures. Attributing the adjacent request to the same defect is an inference based on its matching module-evaluation stack and timing; that record did not include the validation detail. These were overlapping signals, not seven additional incidents.
The two failure paths
The logs showed two manifestations of the same cache mismatch. The exact dummy queue exception was established by matching the production stack to the installed OpenNext source; the raw export recorded the stack but not the exception text.
Cache interception failed before the server rendered
For 30 confirmed 500 responses, the path was:
- A cached route was stale when the request arrived.
- OpenNext's cache interceptor read the cached page.
computeCacheControl()decided that background regeneration was required.- It called
globalThis.queue.send(). - The configured queue was the
dummyimplementation. - The dummy queue threw.
routingHandlercaught the failure and routed the request to/500.- Cloudflare recorded an HTTP 500 response.
The production stack contained Error in routingHandler, computeCacheControl, generateResult, and cacheInterceptor, beginning at Object.send. The installed OpenNext source connected those frames to the throwing dummy queue.
The server could not persist regenerated content
The homepage logs showed the server-side path:
- Next.js rendered a stale page through the server path.
- It attempted to persist the regenerated cache entry.
StaticAssetsIncrementalCache.set()could not write and loggedFailed to set to read-only cache.- Background revalidation attempted to use the queue and failed.
- The request emitted
Failed to revalidate stale pageand a secondary stack trace.
Several records belonged to each request. Counting error lines would therefore have overstated the number of incidents, while deduplicating only by message would have understated them. Request-ID correlation preserved the request boundary.
Choosing a production-safe ISR architecture
Keeping time-based revalidation ruled out the static-assets cache. OpenNext's small-site revalidation guidance pairs two pieces:
- R2 as a writable incremental cache;
- a SQLite-backed Durable Object queue for synchronization, deduplication, and retries.
The account already had an R2 bucket named tentui-opennext-cache, so I reused it. I did not assume that it was empty or already connected to the Worker.
I kept the design narrow:
- No tag cache. TentUI does not call
revalidateTag(),revalidatePath(), orupdateTag(). - No regional cache. The extra layer was unnecessary for this workload.
- No memory queue. OpenNext says it only deduplicates inside one isolate and is not fully suitable for production.
- No direct queue. It is a preview and debugging option, not a production queue.
- No Workers KV cache. OpenNext discourages it here because KV is eventually consistent.
The Durable Object queue is not Cloudflare Queues. It is OpenNext's Durable Object implementation for coordinating ISR work.
The corrected configuration
The final OpenNext configuration uses R2 and the Durable Object queue while retaining cache interception:
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
import doQueue from "@opennextjs/cloudflare/overrides/queue/do-queue";
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
queue: doQueue,
enableCacheInterception: true,
});The Worker needs three matching bindings:
{
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "tent-web"
}
],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "tentui-opennext-cache"
}
],
"durable_objects": {
"bindings": [
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler"
}
]
},
"exports": {
"DOQueueHandler": {
"type": "durable-object",
"storage": "sqlite"
}
},
"secrets": {
"required": ["GITHUB_API_TOKEN"]
}
}WORKER_SELF_REFERENCE lets the queue call the same Worker internally. The R2 and Durable Object binding names are the names expected by the OpenNext overrides.
I used Cloudflare's declarative exports configuration for the new Durable Object namespace and selected SQLite storage. Cloudflare recommends SQLite for new namespaces. The older migrations form remains supported, but exports and migrations should not be combined.
OpenNext's generated Worker already exports DOQueueHandler. I did not write a custom Durable Object class.
The project had also ignored apps/web/wrangler.jsonc. I removed that ignore rule so the real Worker bindings, domains, variables, observability settings, and cache architecture can be reviewed and reproduced from version control.
Deployment must populate R2
The previous deployment script called Wrangler directly. That is insufficient when the external incremental cache needs build-time entries:
{
"scripts": {
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy"
}
}According to the OpenNext CLI documentation, its deploy command first populates the configured remote cache and then invokes Wrangler. R2 cache keys include the build ID, so the build-time cache entries must be populated for every deployment.
This also means cache population and Worker deployment are ordered operations, not one atomic operation. A later Wrangler failure can happen after the remote cache has already changed.
The separate runtime-secret failure
TentUI already has an application-level environment module in packages/env/src/web.ts. It uses @t3-oss/env-nextjs to validate:
GITHUB_API_TOKEN;NEXT_PUBLIC_OPENPANEL_CLIENT_ID;NEXT_PUBLIC_SERVER_URL.
apps/web/next.config.ts calls loadEnvFiles(). That loader reads packages/env/.env, then reads packages/env/.env.prod with override precedence when NODE_ENV === "production".
This supports build-time validation and the inlining of public NEXT_PUBLIC_* values. It does not upload a local secret to a deployed Worker. OpenNext documents build-time and Worker runtime variables as separate configuration surfaces.
GITHUB_API_TOKEN is declared under Wrangler's secrets.required, but that declaration does not install its value. It must be registered once as an encrypted Worker secret:
pnpm --filter web exec wrangler secret put GITHUB_API_TOKENNormal deployments can then remain standard:
pnpm --filter web deployI intentionally removed a temporary custom deployment wrapper. It added another place to move secrets without solving a problem that Wrangler already handles.
I also did not upload the shared .env.prod file to the web Worker. It contains values for other services. Public NEXT_PUBLIC_* values belong to the build, while only server-side secrets needed by the web Worker should be registered at runtime.
The token failure happened before the GitHub request. packages/env/src/web.ts validates GITHUB_API_TOKEN when the module is evaluated, and nav-item-github.tsx imports that environment object at the top level. If the runtime secret is absent, initialization fails before the fetch try/catch can return the zero-star fallback.
This was a second configuration defect, separate from the R2 and queue architecture.
How the corrected request flow works
The resulting request path is:
opennextjs-cloudflare deployuploads build-time cache entries to R2 under the current build ID.- Requests read the prerendered route and data cache from R2.
- Before one day has elapsed, the response is a cache hit.
- The first qualifying request after the entry becomes stale receives stale content instead of waiting for regeneration.
- OpenNext sends a revalidation message to the Durable Object queue.
- The queue deduplicates concurrent attempts and calls the Worker through
WORKER_SELF_REFERENCE. - In the installed OpenNext version, that call is an internal
HEADrequest with revalidation headers. This is an adapter implementation detail, not a Next.js API contract. - Next.js regenerates the stale route and data.
- The R2 incremental cache writes the refreshed result.
- Later requests receive the new cached value.
This follows Next.js's time-based ISR behavior. It does not guarantee regeneration at an exact wall-clock time. If no request arrives after expiry, there is nothing to trigger it.
Verifying the fix locally
I did not use browser automation for this diagnosis.
A small configuration harness initially resolved these OpenNext overrides:
cache: cf-static-assets-incremental-cache
queue: dummyAfter the change, the same harness resolved:
cache: cf-r2-incremental-cache
queue: durable-queueThe static checks passed:
pnpm --filter web check-types;- Biome on the changed configuration;
git diff --check.
pnpm --filter web exec opennextjs-cloudflare build completed and produced .open-next/worker.js. The final build still displayed Revalidate 1d for the affected routes, confirming that I had preserved the product requirement.
A Wrangler dry run bundled the generated Worker and recognized:
NEXT_CACHE_DO_QUEUEasDOQueueHandler;NEXT_INC_CACHE_R2_BUCKETastentui-opennext-cache;WORKER_SELF_REFERENCEastent-web.
For a behavioral proof, I temporarily changed the GitHub cache interval from one day to five seconds in the test build. I then made requests with curl.
/blocks first returned 200 with:
x-opennext-cache: STALEAfter Durable Object revalidation completed, a later request returned 200 with:
x-opennext-cache: HITI restored the source to 86400 and rebuilt. The final build again reported 1d.
The local preview also reproduced the missing GITHUB_API_TOKEN initialization error. That confirmed the separate runtime-secret defect; it did not indicate a problem with the R2 and Durable Object queue.
These checks proved the generated architecture and local stale-to-hit behavior. They were not a production verification of the live Worker.
Verifying production after deployment
Immediately after deployment, I can check representative routes with plain curl:
for path in / /blocks /components /privacy /terms /components/copy-button; do
code=$(curl -sS -o /dev/null -w "%{http_code}" "https://tentui.com$path")
printf "%-40s %s\n" "$path" "$code"
doneEvery route should return 200.
The Worker logs should not contain:
Dummy queue is not implemented
StaticAssetsIncrementalCache: Failed to set
Failed to revalidate stale page
Error in routingHandler
Invalid environment variablesThe definitive time-based production verification happens after one day, on the first request after the cached entry has become stale. That request should remain successful while it triggers background revalidation. Later requests should use the refreshed R2 entry.
What I learned
- A read-only SSG cache and ISR are incompatible, even when most pages look static.
- A cached component in a shared layout can determine route revalidation behavior.
- OpenNext time-based revalidation needs both writable storage and a real queue.
- Use
opennextjs-cloudflare deploywhen an external incremental cache must receive build-time entries. - Correlate logs by request ID before counting incidents.
- Build-time
.envloading and Worker runtime secrets are separate concerns. - Test a stale-to-hit transition with a short local lifetime, then restore the production interval.