# 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(//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.** `SECOND ADDRESS` lines are the split: your links send crawlers to one address while the page's canonical names another. A link that "answers 301" is a wasted hop for every crawler and a sign your links and canonical form disagree. A link to an indexable page that is not in the sitemap is either a page you forgot to list or a duplicate address. ### 2.4 Ask Google what it chose In Google Search Console: 1. **URL Inspection**: inspect the address in your sitemap and read **Page indexing > User-declared canonical** and **Google-selected canonical**. If Google picked the extensionless copy, it will say so there. Google notes that "the live test cannot predict whether or not the tested version will be considered canonical", so read the indexed result, not the live test, for this question. 2. **Pages report** (Indexing > Pages): look for **Duplicate, Google chose different canonical than user**, **Alternate page with proper canonical tag**, and a growing **Discovered, currently not indexed** bucket. Open the examples. If the indexed copies are the extensionless spellings of pages your sitemap lists as `.html`, this guide is your fix. --- ## 3. Decide Pick one row. The deciding question is what your build actually writes to disk and what your canonical tags already say. | Your site | Canonical form | Pretty URLs | |---|---|---| | Folder pages (`about/index.html`), canonicals end in `/` | `/about/` | **Leave it on.** It sends `/about` to `/about/` with a 301, which you cannot do with a redirect rule. Fix path A. | | Flat pages (`about.html`), canonicals and sitemap say `.html` | `/about.html` | **Turn it off**, then redirect the extensionless spellings. Fix path B. | | Flat pages, but you want extensionless addresses | `/about/` (folders) | Change the build to write folder pages and leave it on. Fix path C. | | Mixed: some flat pages, some folder pages | Each page keeps its own file's form | **Turn it off**, fix path B for flat pages, plus the folder slash step in B4. | Which static site generators produce which form by default, from each project's own documentation: | Generator | Default output | How to get the other form | |---|---|---| | Eleventy | Folder: `subdir/template.md` writes `_site/subdir/template/index.html` | `permalink: "subdir/template.html"` in front matter (Eleventy does not recommend it) | | Hugo | Folder ("pretty URLs") | `uglyURLs` produces `section/article.html` | | Astro | Folder: `build.format` defaults to `'directory'` | `build.format: 'file'`. Astro suggests `trailingSlash: 'always'` with `directory` and `'never'` with `file` | | Jekyll | Flat for pages (`about.md` becomes `/about.html`); the default `permalink: date` style for posts ends in `:output_ext`, so posts are flat too | `permalink: pretty` gives folder URLs | | Next.js static export | Flat: `/me` becomes `/me.html` | `trailingSlash: true` emits `/me/index.html` | | Hand written HTML | Whatever files you made, usually flat | Move each page into its own folder as `index.html` | --- ## 4. Fix ### Path A: folder pages, keep Pretty URLs on - [ ] **A1. Canonical tags end in a slash.** ``, absolute, on every page, pointing at itself. - [ ] **A2. Sitemap `` values end in a slash** and match the canonicals character for character, including `https` and `www` or no `www`. - [ ] **A3. hreflang alternates use the same folder form**, absolute, and each language version lists every other version and itself. - [ ] **A4. Internal links use the folder form.** Pretty URLs will rewrite `/about/index.html` links for you, but write them correctly in the templates anyway so the source and the served page agree. - [ ] **A5. Do not write redirect rules that add or remove a trailing slash.** Netlify ignores the slash when matching rules, so they loop. Pretty URLs already answers `/about` with a 301 to `/about/`. - [ ] **A6. Leave `/about/index.html` alone.** It answers 200 on Netlify with the setting on or off. Its canonical tag names `/about/`, and nothing should link to it. - [ ] **A7. Confirm Pretty URLs is on**, in the UI and in `netlify.toml` if you set it there: ```toml [build.processing.html] pretty_urls = true ``` ### Path B: flat pages that keep `.html`, turn Pretty URLs off - [ ] **B1. Turn the setting off** at Project configuration > Developer settings > Post processing > Pretty URLs (uncheck, Save). If `netlify.toml` sets it, change it there too, because the file wins: ```toml [build.processing.html] pretty_urls = false ``` Turn off only this setting. Form detection is a separate setting, and Netlify Forms need it. - [ ] **B2. Write every internal link in the `.html` form** in your templates, menus, footers and any JavaScript that builds links. Search the source for root relative links with no extension, for example `href="/about"`, and change them to `href="/about.html"`. - [ ] **B3. Redirect each extensionless spelling to its `.html` page with a forced 301.** Forcing matters: with the extensionless fallback, Netlify can treat `about.html` as the file at `/about`, which shadows an unforced rule. Because rules ignore the trailing slash, one rule also catches `/about/`. In `_redirects` (in your publish folder): ``` /about /about.html 301! /services /services.html 301! /contact /contact.html 301! /index / 301! ``` Or in `netlify.toml`: ```toml [[redirects]] from = "/about" to = "/about.html" status = 301 force = true ``` Put these rules above any catch-all rule such as `from = "/*"`. Generate the list from your build output rather than typing it, so new pages get a rule too. A small Node example that prints `_redirects` lines for every flat page in a folder named `dist`: ```js // make-redirects.mjs: print a forced 301 for every flat page in ./dist. // Usage: node make-redirects.mjs > redirects-draft.txt (write it OUTSIDE the publish folder) import fs from 'node:fs'; import path from 'node:path'; const root = 'dist'; const walk = (d) => fs.readdirSync(d, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(path.join(d, e.name)) : [path.join(d, e.name)])); for (const f of walk(root)) { if (!f.endsWith('.html') || path.basename(f) === 'index.html' || path.basename(f) === '404.html') continue; const rel = '/' + path.relative(root, f).split(path.sep).join('/'); console.log(`${rel.replace(/\.html$/, '')} ${rel} 301!`); } ``` Review the output, then merge it into your real `_redirects` file. Keep drafts and helper scripts out of the publish folder: Netlify serves every file in it, including ones whose names start with an underscore, except `_redirects` and `_headers`. - [ ] **B4. Folder pages inside a flat site need one more step.** With Pretty URLs off, `/blog` answers 200 from `blog/index.html` instead of redirecting to `/blog/`, and a redirect rule cannot add the slash. A small edge function can. Save as `netlify/edge-functions/dir-slash.js` and list your folder pages: ```js // netlify/edge-functions/dir-slash.js: 301 the slashless spelling of each folder page to its slash form. const DIRS = new Set(['/blog', '/rooms']); export default async (request) => { const url = new URL(request.url); if (!DIRS.has(url.pathname)) return; return new Response(null, { status: 301, headers: { location: url.pathname + '/' + url.search, 'cache-control': 'public, max-age=3600' }, }); }; export const config = { path: ['/blog', '/rooms'] }; ``` Netlify bundles edge functions at deploy time. In my experience a CLI deploy run with `--no-build` can skip that bundling without an error, so after deploying, request `/blog` and confirm the 301. - [ ] **B5. Canonical, sitemap and hreflang stay `.html`**, absolute, and match the links exactly. - [ ] **B6. If you use Netlify Forms**, check each form after the change. On the sites I fixed, form detection still rewrote each detected `
` tag with Pretty URLs off and served its `action` in the extensionless form (`/thanks.html` became `/thanks`). If a form posts to a page, keep that extensionless path answering, for example with a 200 rewrite (`/thanks /thanks.html 200`), because a 301 turns a form POST into a GET and the submission is lost. ### Path C: flat pages, but you want clean addresses The cleanest route is to change what your build writes, not how Netlify serves it. - [ ] **C1. Switch the generator to folder output** (see the table in section 3): Eleventy's default, Hugo without `uglyURLs`, Astro `build.format: 'directory'` with `trailingSlash: 'always'`, Jekyll `permalink: pretty`, Next.js export with `trailingSlash: true`, or hand written pages moved into `name/index.html`. - [ ] **C2. Leave Pretty URLs on.** On a folder style site I measured, a request for `/about.html` where only `about/index.html` exists answered a 301 to `/about/`, so old flat addresses keep working after the switch. Confirm it on yours with the probe in 2.1 before you rely on it. - [ ] **C3. Then do every step of path A** (canonicals, sitemap, hreflang and links all in the folder form). - [ ] **C4. If you cannot change the build output** and still want `/about` without a slash as the canonical form, you would need a forced rule from `/about.html` to `/about` while Netlify serves `/about` from that same file. I have not tested whether that loops on Netlify. Try it on a deploy preview and run the probe in 2.1 against the preview URL before it reaches production. --- ## 5. Deploy, then tell the crawlers - [ ] **5.1 Deploy.** A settings change only takes effect with a new deploy. - [ ] **5.2 Run the probe (2.1) and the site check (2.3) against production.** Expect one 200 per page and one hop for every other spelling. - [ ] **5.3 Resubmit your sitemap in Google Search Console** (Indexing > Sitemaps). Google does not take IndexNow; the sitemap, links and redirects are how it learns the change. - [ ] **5.4 Resubmit the sitemap in Bing Webmaster Tools** if you use it. - [ ] **5.5 Send IndexNow a list that includes both 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). The key file must already be live at the `keyLocation` address: ```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.html","https://example.com/about"]}' ``` A 200 or 202 response means the submission was accepted, not that anything was indexed. - [ ] **5.6 Spend Request Indexing on canonical pages only.** Search Console limits how many you can request each day. Do not spend them on the old spellings; the redirects carry those. --- ## 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 answers 301 to it in one hop (`curl -sI https://example.com/about` shows `location: /about.html` for path B). - [ ] View source on three pages: the `href` values match the canonical form, and no link is in single quotes that your templates wrote in double quotes. - [ ] hreflang alternates are absolute, use the canonical form and answer 200. - [ ] `url-form-check.mjs` reports no `SECOND ADDRESS` lines and no internal links that redirect. - [ ] Forms, if you have them, still submit. Over the next one to two weeks: - The extensionless copies Google had indexed should move to **Page with redirect** in the Pages report. Google's definition: "a non-canonical URL that redirects to another page. As such, this URL will not be indexed." That is the fix working. Do not click Validate fix on that reason; those addresses will redirect forever, so in my experience the validation cannot pass. - Pages that sat in **Discovered, currently not indexed** should start showing a crawl date. Google's description of that bucket is "found by Google, but not crawled yet". - URL Inspection on the canonical address should eventually show your URL as the **Google-selected canonical**. - Bing usually reacts to IndexNow faster than Google reacts to a sitemap, but each engine "makes its own decision about whether to index" a URL (IndexNow FAQ). - AI search crawlers read the HTML your host serves and follow the links in it. OpenAI's and Perplexity's crawler pages say nothing about canonical tags, redirects or sitemaps (checked October 10, 2026), so the safest assumption is that they keep whatever address they fetched. One address everywhere is the only setup that does not depend on how each one decides. --- ## 7. Sources Checked October 10, 2026, unless a different date is given. - Netlify, post processing overview (last updated September 17, 2026): https://docs.netlify.com/build/post-processing/overview/ - Netlify, redirect options, trailing slash handling and force: https://docs.netlify.com/manage/routing/redirects/redirect-options/ - Netlify, rewrites and proxies, shadowing: https://docs.netlify.com/manage/routing/redirects/rewrites-proxies/#shadowing - Netlify, file based configuration, `[build.processing.html] pretty_urls`: https://docs.netlify.com/build/configure-builds/file-based-configuration/ - Netlify, deprecation of post processing asset optimization (July 17, 2023): https://www.netlify.com/blog/deprecation-of-post-processing-asset-optimization-feature/ - Google, how to specify a canonical URL (redirects and rel=canonical are strong signals, sitemap inclusion is weak, link to the canonical URL, do not mix canonicals across methods): https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls - Google, what is canonicalization: https://developers.google.com/search/docs/crawling-indexing/canonicalization - Google, redirects and Google Search (301 and 308 are permanent; 302, 303 and 307 are temporary): https://developers.google.com/search/docs/crawling-indexing/301-redirects - Google, localized versions and hreflang (fully qualified URLs, return links): https://developers.google.com/search/docs/specialty/international/localized-versions - Google, build and submit a sitemap (absolute URLs, list canonical URLs): 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 - Google Search Console Help, URL Inspection tool: https://support.google.com/webmasters/answer/9012289 - IndexNow documentation and FAQ: https://www.indexnow.org/documentation and https://www.indexnow.org/faq ; participating engines: https://www.indexnow.org/searchengines.json - OpenAI crawlers: https://developers.openai.com/api/docs/bots ; Perplexity crawlers: https://docs.perplexity.ai/guides/bots - Eleventy permalinks: https://www.11ty.dev/docs/permalinks/ ; Hugo ugly URLs: https://gohugo.io/configuration/ugly-urls/ ; Astro configuration reference: https://docs.astro.build/en/reference/configuration-reference/ ; Jekyll permalinks, pages and default configuration: https://jekyllrb.com/docs/permalinks/ , https://jekyllrb.com/docs/pages/ and https://jekyllrb.com/docs/configuration/default/ ; Next.js static exports and trailingSlash: https://nextjs.org/docs/app/guides/static-exports and https://nextjs.org/docs/app/api-reference/config/next-config-js/trailingSlash - The measured behavior table in section 1, the form detection note in B6, the `skip_processing` note and the Path C redirect observation are my own tests on live Netlify sites I maintain, October 8 to 10, 2026. Netlify can change this behavior without notice, so run the probe in 2.1 on your own site rather than trusting the table.