# Clean URLs fix guide for Netlify (Pretty URLs)
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-cloudflare.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. Netlify's Pretty URLs setting can quietly break that agreement, because it rewrites the links inside the HTML Netlify serves while leaving your canonical tags and sitemap alone. Google then sees links to one address and canonicals naming another, and it may index the wrong one.
**How to use this file.** Work from top to bottom: diagnose, decide, fix, verify. Every command uses `example.com` and `about` as placeholders; put in your own domain and a real page. 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.
---
## 0. Words used in this file
- **Flat page**: a file such as `about.html`, normally reached at `/about.html`.
- **Extensionless address**: `/about`, with no `.html` and no trailing slash.
- **Folder page**: a file at `about/index.html`, normally reached at `/about/` with a trailing slash.
- **Canonical form**: the one address you want indexed for a page. Pick one form for the whole site.
---
## 1. What Pretty URLs actually does
Netlify documents the setting in two places. The post processing overview says that with it enabled "Netlify rewrites `/about` to `/about/` or `/about.html` to `/about/`". The redirect options page says it is "enabled by default" and that it "forwards paths like `/about` to `/about/`" and "rewrites paths like `/about.html` to `/about/`". The checkbox in the Netlify UI describes the link rewriting differently: `/about.html` becomes `/about`, and `/about/index.html` becomes `/about/`.
What I measured on live Netlify sites between October 8 and 10, 2026:
| Request | Pretty URLs on | Pretty URLs off |
|---|---|---|
| Links inside served HTML, source says `href="/about.html"` | rewritten to `href='/about'` (note the single quotes) | left exactly as written |
| `` and `` | not rewritten | not rewritten |
| `/about` when `about.html` exists | 200, serves the file | 200, serves the file |
| `/about.html` when `about.html` exists | 200 | 200 |
| `/about/` when `about.html` exists | 301 to `/about` | not measured |
| `/about` when `about/index.html` exists | 301 to `/about/` | 200, serves the folder page |
| `/about.html` when only `about/index.html` exists | 301 to `/about/` | not measured |
| `/about/index.html` | 200 | 200 |
| `/index.html` (home page) | 200 | 200 |
Three things in that table cause most of the trouble:
1. **On a flat site, Pretty URLs creates a second address and then links to it.** `/about` and `/about.html` both answer 200 with the same page, and every internal link now points at `/about` while your canonical tag and sitemap still say `/about.html`.
2. **Turning the setting off does not remove the extensionless address.** With it off, `/about` still answers 200 from `about.html`. Only a redirect rule removes the duplicate.
3. **The rewrite happens after upload.** Netlify's list of deployed files shows the bytes you uploaded, not what it serves after post processing. Always check the served HTML (view source in the browser, or curl).
Other facts from Netlify's documentation that matter here:
- The setting lives at **Project configuration > Developer settings > Post processing > Pretty URLs**.
- In `netlify.toml` it is `[build.processing.html]` with `pretty_urls = true` or `false`, and Netlify says the file setting overrides the UI.
- "Enabling post processing features may require a redeploy before changes take effect." Flipping the checkbox changes nothing live until the next deploy.
- Netlify matches redirect rules "regardless of whether or not they contain a trailing slash", and "You cannot use a redirect rule to add or remove a trailing slash". A rule from `/about/` to `/about` is an infinite redirect.
- A rule does not fire when a file exists at the requested path (Netlify calls this shadowing) unless you force it: `301!` in `_redirects`, or `force = true` in `netlify.toml`.
- The old CSS, JavaScript and image "asset optimization" options reached end of service on October 17, 2023, and their `netlify.toml` settings are ignored. On my sites, `[build.processing] skip_processing = true` did not stop Pretty URLs on a CLI deploy. Do not count on it.
---
## 2. Diagnose
### 2.1 What does Netlify 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 and every other spelling answers 301 (or 308) straight to it in one hop. Folder pages are the one exception: `/about/index.html` answers 200 on Netlify whatever you do, and that is fine as long as nothing links to it and its canonical tag names `/about/`.
**Split:** two spellings answer 200. That is a duplicate page, and section 3 removes it.
### 2.2 Do your links match your canonical?
1. Open a page in the browser, press Ctrl+U (Cmd+Option+U on a Mac) to view the source Netlify served, and search for `href=`.
2. Open the same page's file in your build output folder (for example `dist/about.html` or `_site/about/index.html`) and compare the same links.
3. If the source file says `href="/about.html"` and the served page says `href='/about'` in single quotes, Pretty URLs rewrote it.
4. Find the canonical tag in the served page: ``. Write down its form (flat, extensionless or folder).
5. Open `https://example.com/sitemap.xml` and check the form of the same page's ``.
6. If the page has language versions, check each `` too.
All of them should name the same address. If the links say one thing and the canonical, sitemap and hreflang say another, you have the split this guide fixes.
### 2.3 Check the whole site at once
This script reads your sitemap, fetches each page, and reports any page whose canonical disagrees with the sitemap, any internal link or hreflang that lands on a redirect, and any link that reaches a second 200 address for a page. 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; the number caps how many pages it fetches).
```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(/