# 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(//gi, '').replace(//gi, ''); const top = await (await get(sitemapUrl)).text(); let all = locs(top); if (/]+hreflang[^>]*>/gi)) { const h = attr(m[0], 'href'); if (h) linked.set(new URL(h, url).href, `hreflang on ${url}`); } for (const m of visibleHtml(html).matchAll(/]*?\shref\s*=\s*(["']?)([^"'\s>]+)\1/gi)) { if (m[2].includes('${')) continue; // an unexpanded JavaScript template, not a real link let u; try { u = new URL(m[2], url); } catch { continue; } if (u.origin !== origin) continue; const f = form(u.pathname); if (f in linkForms) linkForms[f]++; if (!linked.has(u.origin + u.pathname)) linked.set(u.origin + u.pathname, `a link on ${url}`); } } let checked = 0; for (const [u, where] of linked) { if (inSitemap.has(u) || checked >= 200 || form(new URL(u).pathname) === 'file') continue; checked++; const r = await get(u); if (r.status >= 300 && r.status < 400) { problems.push(`${where} points at ${u}, which answers ${r.status} to ${r.headers.get('location')}`); continue; } if (r.status !== 200) { problems.push(`${where} points at ${u}, which answers ${r.status}`); continue; } const html = await r.text(); if (/]+name=["']?robots["']?[^>]+noindex/i.test(html)) continue; const canon = canonicalOf(html, u); if (canon && canon !== u) problems.push(`SECOND ADDRESS: ${where} points at ${u} (200), whose canonical is ${canon}`); else problems.push(`${where} points at ${u} (200, indexable) but it is not in the sitemap`); } console.log(`sitemap URLs: ${all.length}, pages fetched: ${pages.length}`); console.log('sitemap URL forms (fetched pages):', JSON.stringify(sitemapForms)); console.log('internal link forms in the served HTML:', JSON.stringify(linkForms)); console.log(problems.length ? `\n${problems.length} findings:\n` + problems.map((p) => ' ' + p).join('\n') : '\nno findings: every signal agrees'); ``` **How to read it on Cloudflare.** "sitemap URL answers 308" or "307" means your sitemap and probably your canonical tags still use `.html` (or the wrong slash) and the host is redirecting them. Links that "answer 308" are the same problem in your templates. ### 2.4 Ask Google what it chose In Google Search Console, inspect a sitemap address with **URL Inspection** and compare **User-declared canonical** with **Google-selected canonical** (read the indexed result; Google says the live test "cannot predict whether or not the tested version will be considered canonical"). In the **Pages** report, a sitemap full of `.html` addresses on Pages shows up under **Page with redirect**. --- ## 3. Decide | Your setup | Canonical form to use | |---|---| | Pages, flat files (`about.html`) | `/about` (extensionless). Pages serves it there and redirects `.html` for you. | | Pages, folder pages (`about/index.html`) | `/about/` | | Pages, a mix | Each page in the form its file gets: flat files extensionless, folder pages with a slash | | Pages, and you must keep `.html` addresses | I found no documented way to do it on Pages. Move the site to Workers static assets with `html_handling = "none"` (path W), or accept the extensionless form. | | Workers static assets | Choose the mode that matches the form you want, then make every tag agree (path W) | Which static site generators write which files, from each project's documentation: | Generator | Default output | Other form | |---|---|---| | Eleventy | Folder: `subdir/template.md` writes `_site/subdir/template/index.html` | `permalink: "subdir/template.html"` | | Hugo | Folder | `uglyURLs` gives `section/article.html` | | Astro | Folder (`build.format: 'directory'`) | `build.format: 'file'`; Astro suggests `trailingSlash: 'always'` with `directory` and `'never'` with `file` | | Jekyll | Flat for pages; the default `permalink: date` for posts ends in `:output_ext` | `permalink: pretty` gives folder URLs | | Next.js static export | Flat (`/me.html`) | `trailingSlash: true` gives `/me/index.html` | Most generators also have a setting for the site URL and for how they print canonical and sitemap URLs. That is where the fix usually goes: the generator writes `.html` into the canonical tag while Pages serves the page without it. --- ## 4. Fix ### Path P: Cloudflare Pages - [ ] **P1. Canonical tags use the form Pages serves**: `https://example.com/about` for flat files, `https://example.com/about/` for folder pages. Absolute, on every page, pointing at itself. - [ ] **P2. Sitemap `` values match the canonicals exactly**, including `https` and `www` or no `www`. No `.html` endings, no `/index.html`. - [ ] **P3. hreflang alternates use the same forms**, absolute, and each language version lists every other version and itself. - [ ] **P4. Internal links in templates, menus, footers and scripts use the same forms.** Pages will redirect a `.html` link, but every such link costs crawlers a hop and keeps sending them to the address you are trying to retire. - [ ] **P5. Do not add `_redirects` lines for `.html` to extensionless.** Pages already does it with a 308. Add lines only for addresses that changed for other reasons, and always give a status: ``` /old-page /new-page 301 /services.htm /services 301 ``` - [ ] **P6. Watch the limits**: 2,000 static plus 100 dynamic redirects, 1,000 characters a line, and no `_redirects` on routes served by Pages Functions (handle those in the Function). - [ ] **P7. If your site sits behind your own Cloudflare zone with extra rules**, check that no Redirect Rule, Page Rule or Transform Rule adds or strips `.html` or slashes in a way that fights Pages. Request each spelling with the probe and confirm one hop. ### Path W: Workers static assets - [ ] **W1. Pick a mode** in your Wrangler config that matches the canonical form you want: ```toml [assets] directory = "./dist/" html_handling = "auto-trailing-slash" # flat files at /about, folder pages at /about/ # html_handling = "drop-trailing-slash" # everything without a slash # html_handling = "force-trailing-slash" # everything with a slash # html_handling = "none" # serve files exactly as named, /about.html stays /about.html ``` Or in `wrangler.jsonc`: ```jsonc { "assets": { "directory": "./dist/", "html_handling": "auto-trailing-slash" } } ``` - [ ] **W2. Make canonicals, sitemap, hreflang and internal links match the mode**, exactly as in P1 to P4. With `none`, they keep the `.html` form, and `/about` falls through to your `not_found_handling`. - [ ] **W3. Give already indexed addresses a permanent redirect.** The built-in redirects are 307s, which Google treats as temporary. For each old spelling that search engines know, add a 301 in `_redirects` in your assets directory: ``` /about.html /about 301 /services.html /services 301 ``` Cloudflare documents that `_redirects` rules are followed even when an asset matches, but it does not spell out the order between `_redirects` and `html_handling`. After deploying, run the probe and confirm `/about.html` answers 301, not 307. - [ ] **W4. Watch the same limits as Pages** (2,000 static plus 100 dynamic, 1,000 characters a line), and remember that requests your Worker code answers skip `_redirects`. --- ## 5. Deploy, then tell the crawlers - [ ] **5.1 Deploy** (`wrangler pages deploy` or `wrangler deploy`). - [ ] **5.2 Run the probe (2.1) and the site check (2.3) against production.** - [ ] **5.3 Resubmit your sitemap in Google Search Console** (Indexing > Sitemaps). Google does not take IndexNow. - [ ] **5.4 Resubmit it in Bing Webmaster Tools** if you use it. - [ ] **5.5 Send IndexNow the canonical pages and the old spellings that now redirect.** The IndexNow FAQ says "You should submit redirected URLs". One submission reaches every participating engine (Bing, Yandex, Seznam, Naver, Yep, the Internet Archive and Amazonbot are listed at indexnow.org as of October 10, 2026). A manual submission, with the key file already live at `keyLocation`: ```bash curl -s -X POST https://api.indexnow.org/indexnow \ -H 'Content-Type: application/json; charset=utf-8' \ -d '{"host":"example.com","key":"YOUR_INDEXNOW_KEY","keyLocation":"https://example.com/YOUR_INDEXNOW_KEY.txt","urlList":["https://example.com/about","https://example.com/about.html"]}' ``` - [ ] **5.6 Spend Request Indexing on canonical pages only.** Search Console limits how many you can request each day. --- ## 6. Verify, and what to expect Right after the deploy: - [ ] Every sitemap URL answers 200, and its canonical tag names itself. - [ ] Every other spelling of each page reaches it in one hop. - [ ] Old `.html` addresses answer 301 or 308, not 307, if they were ever indexed. - [ ] View source on three pages: links, canonical and hreflang all use the same form. - [ ] `url-form-check.mjs` reports no sitemap URLs that redirect and no `SECOND ADDRESS` lines. Over the next one to two weeks: - Sitemap addresses that were redirecting should stop appearing under **Page with redirect** once Google rereads the sitemap, and the extensionless addresses should show your URL as the **Google-selected canonical** in URL Inspection. - Old `.html` addresses Google had indexed should move to **Page with redirect**: "a non-canonical URL that redirects to another page". That is the fix working. In my experience, clicking Validate fix on that reason cannot pass, because those addresses will redirect forever. - Bing usually reacts to IndexNow faster than Google reacts to a sitemap, but each engine "makes its own decision about whether to index" (IndexNow FAQ). - AI search crawlers read the HTML Cloudflare serves and follow its links. OpenAI's and Perplexity's crawler pages say nothing about canonical tags, redirects or sitemaps (checked October 10, 2026), so assume they keep the address they fetched, and make sure your links point at the canonical one. --- ## 7. Sources Checked October 10, 2026, unless a different date is given. - Cloudflare, Serving Pages (route matching): https://developers.cloudflare.com/pages/configuration/serving-pages/ - Cloudflare, Pages redirects (`_redirects` format, 302 default, limits, Functions caveat): https://developers.cloudflare.com/pages/configuration/redirects/ - Cloudflare, Workers static assets HTML handling (modes and 307 tables): https://developers.cloudflare.com/workers/static-assets/routing/advanced/html-handling/ - Cloudflare, Workers static assets redirects: https://developers.cloudflare.com/workers/static-assets/redirects/ - Google, redirects and Google Search (301 and 308 permanent; 302, 303 and 307 temporary): https://developers.google.com/search/docs/crawling-indexing/301-redirects - Google, how to specify a canonical URL: https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls - Google, localized versions and hreflang: https://developers.google.com/search/docs/specialty/international/localized-versions - Google, build and submit a sitemap: https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap - Google Search Console Help, Page indexing report: https://support.google.com/webmasters/answer/7440203 ; URL Inspection: https://support.google.com/webmasters/answer/9012289 - IndexNow FAQ and participating engines: https://www.indexnow.org/faq and https://www.indexnow.org/searchengines.json - OpenAI crawlers: https://developers.openai.com/api/docs/bots ; Perplexity crawlers: https://docs.perplexity.ai/guides/bots - Generator documentation: https://www.11ty.dev/docs/permalinks/ , https://gohugo.io/configuration/ugly-urls/ , https://docs.astro.build/en/reference/configuration-reference/ , https://jekyllrb.com/docs/permalinks/ , https://jekyllrb.com/docs/configuration/default/ , https://nextjs.org/docs/app/guides/static-exports - The Pages status codes in section 1.1 and the byte for byte comparison are my own tests of two live Pages projects on October 10, 2026. Cloudflare can change this behavior without notice, so run the probe on your own site rather than trusting any table here.