I serve an Angular site on Cloudflare Pages. One day I opened https://site.example.com/llms.txt and got Angular HTML inside. Same for llms-full.txt, with Content-Type: text/html. Text I prepared for generative AI had been replaced wholesale by the SPA shell.
It turned out to be Cloudflare Pages' "implicit SPA mode"—easy to trip over if you do not know it—so I am writing it down.
What was actually happening
Cloudflare Pages enables SPA mode automatically when the build output does not include 404.html. In SPA mode, Pages returns index.html for requests with no matching asset. That helps client-side routing in React or Angular apps, but the line between "what counts as an asset" depends on file extension.
Pages seems to judge assets by extension: .xml is treated as a known static asset, but .txt is not recognized. So sitemap.xml returns the file normally while llms.txt goes through SPA fallback and returns Angular's index.html—counterintuitive.
The file exists at dist/site/browser/llms.txt, but the response is HTML. It looks like a server misconfiguration, but Cloudflare Pages' "helpful" SPA mode was swallowing the text file.
Fix: add 404.html to disable SPA mode
The docs say Pages disables SPA mode when 404.html is in the build output and serves all static files as-is. Adding 404.html to the Angular build output fixes it.
My site prerenders every route with Angular prerender, so I never needed SPA fallback. I chose to turn SPA mode off.
On Angular v19 prerender builds, index.csr.html is generated as the client-side rendering template—I copy it to 404.html. Missing routes still show Angular's NotFoundComponent with the same UX as before, but HTTP status is correctly 404.
I added the copy step to postbuild:site in package.json.
"postbuild:site": "cp projects/site/wrangler.toml dist/site/wrangler.toml && ([ -f dist/site/browser/index.csr.html ] && cp dist/site/browser/index.csr.html dist/site/browser/404.html || true) && ([ -f dist/site/browser/en/index.csr.html ] && cp dist/site/browser/en/index.csr.html dist/site/browser/en/404.html || true)"
With i18n split into ja and en locales, I place 404.html for each.
[ -f ] chained with && is a trap
At first I wrote && [ -f FILE ] && cp FILE DEST plainly. When the file is missing, [ -f ] exits 1 and the rest of the && chain is skipped. From npm scripts that ends exit code 1 and fails the build. A "skip safely when missing" guard ended up breaking the build.
So I wrap each copy in a subshell: ([ -f FILE ] && cp FILE DEST || true). If [ -f ] fails inside the subshell, || true recovers—"copy if present, otherwise move on" independently. If Angular stops generating index.csr.html later, the build still passes.
Why not _routes.json?
I first wondered if _routes.json could fix it. _routes.json is for Pages Functions—controlling which requests hit Functions vs static assets—not SPA fallback itself.
My site uses no Pages Functions; no /functions directory or _worker.js. Every route is prerendered static. Adding _routes.json would not fix .txt SPA fallback.
What about wrangler.toml not_found_handling?
Another option: [assets] not_found_handling = "404-page" in wrangler.toml. That is Cloudflare Workers Static Assets via an [assets] section. It does not apply when the project points at Pages build output (pages_build_output_dir). Using it means migrating Pages to Workers—out of scope here.
The simplest, least-dependent fix was adding 404.html to the build output.
robots.txt behavior I noticed while verifying
While checking, I noticed robots.txt is served by Cloudflare's "Managed robots.txt" feature—not my file verbatim, but a Cloudflare-edited version.
Specifically, header comments and Content-Signal explanations are injected at the top, plus blocks that Disallow: / for specific crawlers (Amazonbot, GPTBot, ClaudeBot, Google-Extended, etc.).
# As a condition of accessing this website, you agree to abide by the following
# content signals:
...
# BEGIN Cloudflare Managed content
User-agent: *
Content-Signal: search=yes,ai-train=no
Allow: /
User-agent: Amazonbot
Disallow: /
User-agent: GPTBot
Disallow: /
...
# END Cloudflare Managed Content
User-agent: *
Allow: /
Sitemap: https://site.example.com/sitemap.xml
LLMs-Txt: https://site.example.com/llms.txt
Compared to the simple robots.txt I intended, Content-Signal and many AI crawler Disallows are Managed—it is a different file. For fine-grained generative-AI site settings, check Managed robots.txt in the Cloudflare Dashboard.
Summary
If .txt files turn into HTML on Cloudflare Pages, first check whether 404.html is in the build output. SPA mode is convenient but can surprise you with text files. If every route is prerendered, explicitly adding 404.html and disabling SPA mode gives straightforward responses.
When writing conditional copy in build scripts, chaining with && can have unexpected side effects—wrap in subshells with || true for safe recovery. Small detail, but others may hit the same trap.
See you next time.