# Clean URLs fix guide for Cloudflare (Pages and Workers static assets)
Version 1.0, October 10, 2026. Written by J.A. Watte (https://jwatte.com). Free to copy, adapt and share. No signup, no attribution required.
Source article: https://jwatte.com/blog/netlify-pretty-urls-crawlers/
Other hosts: https://jwatte.com/downloads/clean-urls-fix-netlify.md and https://jwatte.com/downloads/clean-urls-fix-vercel.md
**What this fixes.** Every page on your site should have one address, and every signal you send about that page should name it: the canonical tag, the sitemap, hreflang, your internal links and your redirects. Cloudflare decides part of that for you. Pages always redirects `/about.html` to `/about`, and Workers static assets do the same by default. If your canonical tags, sitemap or hreflang still say `/about.html`, every address you declare to search engines is a redirect, and Google is left choosing between your tags and the host's redirects.
**How to use this file.** Work from top to bottom: find which Cloudflare product serves your site, diagnose, decide, fix, verify. Every command uses `example.com` and `about` as placeholders. If you hand this file to an AI coding agent, tell it to change the template, generator or config that builds your pages, never a single built HTML file, or the next build undoes the fix.
**How this differs from Netlify.** Netlify's Pretty URLs setting rewrites the links inside the HTML it serves. I found no Cloudflare documentation describing anything that edits your links, and on the Pages project I checked, the served HTML was byte for byte the file I deployed. So on Cloudflare the links on your pages are exactly what your build wrote. The trouble comes from the other direction: the host redirects addresses your tags still declare.
---
## 0. Words used in this file
- **Flat page**: a file such as `about.html`.
- **Extensionless address**: `/about`, with no `.html` and no trailing slash.
- **Folder page**: a file at `about/index.html`, normally reached at `/about/`.
- **Canonical form**: the one address you want indexed for a page.
Which product are you on? If you deploy with `wrangler pages deploy`, or your project shows under Workers & Pages as a Pages project with a `*.pages.dev` address, read the Pages sections. If your `wrangler.toml` or `wrangler.jsonc` has an `[assets]` block with a `directory`, you are on Workers static assets.
---
## 1. What Cloudflare does with your HTML files
### 1.1 Cloudflare Pages
Cloudflare's "Serving Pages" documentation: "Pages will also redirect HTML pages to their extension-less counterparts: for instance, `/contact.html` will be redirected to `/contact`, and `/about/index.html` will be redirected to `/about/`."
What I measured on two live Pages projects on October 10, 2026:
| Request | Answer |
|---|---|
| `/404.html` (a flat file) | 308 to `/404` |
| `/404` | 200 |
| `/404/` | 308 to `/404` |
| `/accessibility` (a folder page) | 308 to `/accessibility/` |
| `/accessibility/` | 200 |
| `/accessibility/index.html` | 308 to `/accessibility/` |
| `/accessibility/index` | 308 to `/accessibility/` |
| `/index.html` and `/index` | 308 to `/` |
308 is a permanent redirect, which Google treats the same way as a 301. The Pages documentation I read describes no setting to turn this behavior off. In practice the canonical form on Pages is decided by your files: a flat file is served at `/about`, a folder page at `/about/`.
Pages `_redirects` facts that matter for this fix:
- The status code defaults to **302** (temporary). Always write `301` or `308` for permanent moves.
- "Redirects are always followed, regardless of whether or not an asset matches the incoming request." No force flag is needed.
- A `_redirects` file holds at most 2,000 static and 100 dynamic redirects, and each line is limited to 1,000 characters. Bulk Redirects handle more.
- `_redirects` rules are not applied to requests served by Pages Functions.
### 1.2 Workers static assets
The `assets.html_handling` setting in your Wrangler config controls the same thing, and you can choose. From Cloudflare's "HTML handling" documentation, for a site with `file.html` and `folder/index.html`:
| Mode | `/file` | `/file.html` | `/file/` | `/folder` | `/folder/` | `/folder/index.html` |
|---|---|---|---|---|---|---|
| `auto-trailing-slash` (default) | 200 | 307 to `/file` | 307 to `/file` | 307 to `/folder/` | 200 | 307 to `/folder` |
| `force-trailing-slash` | 307 to `/file/` | 307 to `/file/` | 200 | 307 to `/folder/` | 200 | 307 to `/folder/` |
| `drop-trailing-slash` | 200 | 307 to `/file` | 307 to `/file` | 200 | 307 to `/folder` | 307 to `/folder` |
| `none` | per `not_found_handling` | 200 | per `not_found_handling` | per `not_found_handling` | per `not_found_handling` | 200 |
Two things stand out:
1. **These redirects are 307s.** Google's redirect documentation classes 307 as temporary: "Googlebot follows the redirect, but the indexing pipeline doesn't use the redirect as a signal that the redirect target should be canonical." Your canonical tags, sitemap and links have to carry the canonical signal on their own, and for old addresses that were already indexed you should add real 301s (section 4, path W).
2. **Watch for a two hop chain.** Cloudflare's own table lists `/folder/index.html` answering 307 to `/folder` (no slash) under `auto-trailing-slash`, and `/folder` answering 307 to `/folder/`. If your site behaves that way, that is two hops. Test it with the probe below; if you see two, add a direct 301 in `_redirects`.
Workers static assets accept a `_redirects` file with the same format and limits as Pages, the same 302 default, and the same rule that redirects are followed whether or not an asset matches. Rules are not applied to requests your Worker code serves.
---
## 2. Diagnose
### 2.1 What does Cloudflare answer for each spelling of one page?
Save as `probe-url-forms.sh` and run `bash probe-url-forms.sh https://example.com about`:
```bash
#!/usr/bin/env bash
# What does the host answer for each spelling of one page? Read only.
base="$1"; page="$2"
for path in "/$page" "/$page.html" "/$page/" "/$page/index.html"; do
curl -s -o /dev/null -w '%{http_code} %{redirect_url} <= '"$base$path"'\n' "$base$path"
done
```
On Windows, run it in Git Bash or WSL, or call `curl.exe` in PowerShell with `-o NUL` instead of `-o /dev/null`.
**Healthy:** exactly one spelling answers 200, every other spelling redirects to it in one hop, and that one spelling is the address in your canonical tag and sitemap.
**The Cloudflare failure:** the spelling that answers 200 is `/about`, but your canonical tag and sitemap say `/about.html`, which answers 308 (Pages) or 307 (Workers). Your declared canonical is a redirect.
### 2.2 Do your tags match what Cloudflare serves?
1. View the source of a live page (Ctrl+U, or Cmd+Option+U on a Mac) and find ``.
2. Request that exact canonical address with the probe or `curl -sI`. It must answer 200, not a redirect.
3. Open `https://example.com/sitemap.xml` and do the same for the page's ``.
4. Check every `` on the page the same way.
5. Check a handful of internal links in the source. Each should go straight to a 200.
### 2.3 Check the whole site at once
This script reads your sitemap, fetches each page, and reports sitemap URLs that redirect, canonicals that disagree with the sitemap, internal links and hreflang that land on redirects, and links that reach a second 200 address. It only sends GET requests. Save as `url-form-check.mjs` and run `node url-form-check.mjs https://example.com/sitemap.xml 50` (Node 18 or newer).
```js
// url-form-check.mjs: do your sitemap, canonical tags, hreflang and internal links all name
// the same address for each page? Read only: it sends GET requests and changes nothing.
// Usage: node url-form-check.mjs https://example.com/sitemap.xml [maxPages]
// Needs Node 18 or newer.
const sitemapUrl = process.argv[2];
const maxPages = Number(process.argv[3] || 50);
if (!sitemapUrl) { console.log('usage: node url-form-check.mjs https://example.com/sitemap.xml [maxPages]'); process.exit(1); }
const get = (u) => fetch(u, { redirect: 'manual', headers: { 'user-agent': 'url-form-check/1.0' } });
const locs = (xml) => [...xml.matchAll(/\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1].replace(/&/g, '&'));
const form = (p) => (p.endsWith('/') ? 'folder' : /\.html?$/i.test(p) ? 'flat' : /\.[a-z0-9]{1,5}$/i.test(p) ? 'file' : 'extensionless');
const attr = (tag, name) => tag.match(new RegExp(name + '\\s*=\\s*["\']?([^"\'\\s>]+)', 'i'))?.[1];
const canonicalOf = (html, base) => {
const tag = (html.match(/]+rel=["']?canonical["']?[^>]*>/i) || [''])[0];
const h = attr(tag, 'href');
return h ? new URL(h, base).href : null;
};
const visibleHtml = (html) => html.replace(/