← ForeA Technologies Blog index
Engineering notes — Cloudflare Workers · @opennextjs/cloudflare

The .html file that deployed clean and 404’d anyway

A one-line static file, uploaded successfully by wrangler, present in the build output, byte-correct on disk — and unreachable in production. Here's the Cloudflare config nobody warns you about until you ship a raw .html asset.

Read — 5 min Stack — Next.js 16 · @opennextjs/cloudflare 1.20 · Wrangler 4

01The one file shape we'd never deployed

Every static asset in our Next.js app's public/ folder had, until this week, been an image — a favicon, an OG card. Then Google Search Console asked for a domain-ownership file: a single line of text, saved at the site root as googleea55b27a22436d57.html. Exact filename, exact path, served byte-for-byte, forever, or the verification silently lapses.

We dropped it in public/, built, and deployed to Cloudflare Workers via @opennextjs/cloudflare — the same pipeline that had shipped dozens of production deploys already.

02It deployed. It also 404’d.

Wrangler's own log said the file made it to the edge:

wrangler deploy --env production
🌀 Found 2 new or modified static assets to upload. Proceeding with upload... + /BUILD_ID + /googleea55b27a22436d57.html Uploaded 2 of 2 assets ✨ Success! Uploaded 2 files (52 already uploaded)

A request to the live URL said otherwise:

curl -D - https://asksealed.com/googleea55b27a22436d57.html
HTTP/2 404 x-powered-by: Next.js x-nextjs-cache: MISS x-nextjs-prerender: 1 # body: Next's own "This page could not be found" React tree

That x-powered-by: Next.js header is the whole story in one line, if you know to look for it. This 404 didn't come from Cloudflare saying "no such asset." It came from the Next.js application itself, rendering its own not-found page for a route it had never heard of — meaning the request never reached the asset at all.

03Ruling out the obvious

Before chasing Cloudflare's routing internals, we checked whether static assets were broken in general, by requesting one that had worked in every prior deploy:

/og-image.png200
content-type: image/png cf-cache-status: MISS etag: "3513ecfb...951e7a"
/google...html404
content-type: text/html x-powered-by: Next.js x-nextjs-prerender: 1

Same public/ folder, same wrangler upload step, same asset manifest. One extension worked; the other didn't. That narrowed it to something extension-specific in how Cloudflare decides what answers a request — not a general static-asset failure.

04The actual cause: html_handling

Cloudflare Workers' Static Assets binding ships an opinionated default aimed at static sites with clean, extension-less URLs. It's called html_handling, and it isn't something @opennextjs/cloudflare or Next.js ever ask you to configure — it just applies, silently, underneath both.

ModeBehavior
auto-trailing-slash
(default)
/file.html redirects to /file; /folder/ serves /folder/index.html.
force-trailing-slashEvery HTML path is forced to carry a trailing slash.
drop-trailing-slashTrailing slashes are stripped instead of added.
none/file.html is served exactly as requested. No folder→index rewriting.

With the default left in place, a request for /googleea55b27a22436d57.html gets redirected toward the extensionless /googleea55b27a22436d57 before Cloudflare ever checks whether that .html file exists as an asset. The extensionless path isn't a registered asset — nothing in .open-next/assets is named that — so the request falls through to whatever else is wired up to catch unmatched paths. For an OpenNext deploy, that's the Next.js Worker itself, which dutifully renders its own 404 for a route it doesn't recognize. Two independently correct systems, stacked in a way that produces a wrong answer.

Not just us

This isn't a misconfiguration on our end so much as a gap in what the four modes cover. An open Cloudflare issue (workers-sdk#10671) asks for exactly the mode this needs — literal .html filenames preserved and /folder/ still mapping to /folder/index.html — and it doesn't exist yet.

05The fix

One line in wrangler.jsonc, on the assets block:

wrangler.jsonc
"assets": { "directory": ".open-next/assets", "binding": "ASSETS" "binding": "ASSETS", "html_handling": "none" }

Trade-off, and why it's a non-issue here: "none" also turns off the automatic /folder//folder/index.html mapping. That only matters if you have static HTML pages relying on it — a Next.js app's own routes are handled entirely by the Worker and never carry a .html extension regardless, so the only thing this setting touches is the one raw .html asset we actually shipped.

06Verifying it for real

Rebuilt, redeployed, and — this time — checked the literal production URL again rather than trusting the deploy log a second time:

curl -D - https://asksealed.com/googleea55b27a22436d57.html
HTTP/2 200 content-type: text/html cf-cache-status: HIT google-site-verification: googleea55b27a22436d57.html

Clean 200, correct content-type, exact expected body — and none of the x-nextjs-* headers that had given away the wrong code path the first time around.

07The takeaway

A green wrangler deploy tells you a file reached the edge. It doesn't tell you which layer answers a request for it first — and on a hybrid deploy like OpenNext's, where a Worker and a static-assets binding both have opinions about the same path, those can disagree in ways that look, from the outside, exactly like an app bug instead of a routing default. If you're about to ship a raw .html file on Cloudflare Workers Static Assets, check html_handling before you need the debugging session.

References

We write these up as we hit them. This one came out of shipping Sealed, ForeA's proposal-tracking product, on Next.js and Cloudflare Workers.