Abdolmadjid Masoomi

technical-essay · 2026-09-07 · 5 min read

Your Prerendered Pages Return 404 on Cloudflare Workers

A Next.js dynamic route builds as SSG, deploys without error, and 404s in production. The cause is one missing line of configuration, and nothing in the build will tell you.

Status: verified

I published three articles on a Next.js site deployed to Cloudflare Workers. The build prerendered all three. The deploy reported success. Every one of them returned 404 in production, while every other page on the site worked perfectly.

The cause is a single missing line of configuration and a build step that wrangler deploy does not run. It took a while to find, because every signal available said the deployment was fine.

The symptom

The build output was unambiguous. All three articles were prerendered as SSG:

Route (app)
├ ○ /about
├ ○ /contact
└ ƒ /writing/[slug]
  ├ ● /writing/a-ledger-that-refuses-the-claim
  ├ ● /writing/nonce-csp-static-prerender
  └ ● /writing/exit-zero-means-nothing

means prerendered as static HTML using generateStaticParams. Then in production:

/writing                                    200
/writing/nonce-csp-static-prerender         404
/writing/exit-zero-means-nothing            404
/writing/a-ledger-that-refuses-the-claim    404

The index page worked. Every article 404'd. And /writing rendered its empty state — "No publications yet." — so the worker was not merely failing to route, it was seeing zero documents.

The wrong diagnosis I nearly shipped

The content loader reads from disk:

export const CONTENT_ROOT = path.join(process.cwd(), 'content');

export function loadCollection(dir: string): Doc[] {
  const full = path.join(CONTENT_ROOT, dir);
  if (!fs.existsSync(full)) return [];
  // ...
}

Cloudflare Workers has no filesystem at runtime. The obvious conclusion is that node:fs silently returns nothing there, and the empty state is the loader failing.

That conclusion is wrong, and checking is what showed it. The .mdx files are bundled into the worker:

.open-next/server-functions/default/content/writing/nonce-csp-static-prerender.mdx

OpenNext traces the files the server code touches and includes them. The filesystem access works. That was not the problem.

The actual cause

Alongside the bundled content, the build had written this:

.open-next/cache/mZN5_YYESWszFQ4D1ZEk6/writing/nonce-csp-static-prerender.cache

The prerendered output existed. It was sitting in a cache directory that nothing could read, because open-next.config.ts said:

export default defineCloudflareConfig();

No incremental cache adapter.

OpenNext serves prerendered pages of a dynamic route through the incremental cache. Not from the static asset bucket, the way a plain static route like /about is served. A route with [slug] and generateStaticParams produces cache entries, and without an adapter there is nothing to read them with. The worker falls through to a 404.

This is why the failure looked so strange: it affects only parameterised routes. Every plain static route was unaffected, so the site appeared healthy.

I had configured no adapter deliberately, and my reasoning is worth quoting because the first half of it was correct:

24 of 25 routes are prerendered and none use ISR, so a cache adapter would provision R2 or KV to serve traffic that does not exist.

Right about the cost. Wrong about the conclusion. Not needing revalidation is not the same as not needing something to serve prerendered output from.

The fix, both halves

There is an adapter for exactly this case, and its own documentation describes it precisely:

This cache uses Workers static assets. It should only be used for applications that do NOT want revalidation and ONLY want to serve prerendered data.

import { defineCloudflareConfig } from '@opennextjs/cloudflare';
import incrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/static-assets-incremental-cache';

export default defineCloudflareConfig({ incrementalCache });

No R2 bucket, no KV namespace, no per-request cost. The prerendered pages ride along as static assets.

That alone was not enough. The cache entries have to be copied into the assets directory, and that is a separate build step:

.open-next/cache/<buildId>/…   →   .open-next/assets/cdn-cgi/_next_cache/<buildId>/…

The step is populateCache, and it runs as part of opennextjs-cloudflare deploy. I was deploying with wrangler deploy directly, which never runs it. If you deploy with plain wrangler — for CI reasons, or to control the deploy yourself — you must chain it:

{
  "scripts": {
    "cf:build": "opennextjs-cloudflare build && opennextjs-cloudflare populateCache remote"
  }
}

After that, 27 cache entries appeared under .open-next/assets/cdn-cgi/_next_cache/, and all three articles returned 200.

How to tell whether this is your bug

Three checks, in order:

  1. Is it only parameterised routes? If /about works and /blog/[slug] 404s, this is very likely it. A general routing or deploy failure does not discriminate by route shape.
  2. Do the cache entries exist?
    find .open-next/cache -name "*.cache" | head
    
    If they exist, the build did its job and the problem is downstream.
  3. Did they reach the assets?
    find .open-next/assets -path "*_next_cache*" -type f | wc -l
    
    Zero here, with entries present in step 2, is the diagnosis.

Why nothing caught it earlier

The site had no published articles when the cache adapter was configured away. generateStaticParams returned an empty array, so the route produced no prerendered pages, so there were no cache entries, so nothing needed reading. The configuration was wrong from the moment it was written and could not fail until content existed.

That is the pattern worth taking away, more than the specific fix: a configuration mistake in a code path that no data currently exercises is not dormant, it is undetected. It fails on the day the feature is first genuinely used, which is the worst possible day, and it fails looking like a content problem rather than a configuration one.

The general check is cheap. When a route type has zero instances — no articles, no products, no users — the code serving it has never actually run. Create one and exercise it before you believe that path works.

nextjscloudflare-workersopennextdeployment