# Clean URLs fix guide for Vercel (cleanUrls and trailingSlash) 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-cloudflare.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. On Vercel two settings in `vercel.json` decide which addresses answer: `cleanUrls` and `trailingSlash`. Their defaults leave some pages reachable at two addresses, and Vercel's own documentation warns that one of them "could lead to search engines indexing two different pages with duplicate content." **How to use this file.** Work from top to bottom: 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. Vercel's documentation describes nothing that edits your links: `cleanUrls` and `trailingSlash` change which addresses answer and redirect, not what your pages say. So on Vercel, a split between links and canonicals comes from your own templates, and a duplicate comes from settings left at their defaults. I have not measured a Vercel site for this guide; everything below comes from Vercel's and Next.js's documentation as of October 10, 2026, so run the probe in 2.1 on your own site. --- ## 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. --- ## 1. What Vercel does, from its documentation ### 1.1 `cleanUrls` (default `false`) "When set to `true`, all HTML files and Vercel functions will have their extension removed. When visiting a path that ends with the extension, a 308 response will redirect the client to the extensionless path." Vercel's example: `about.html` is served at `/about`, and `/about.html` redirects to `/about`. The documentation describes only the `true` case. With it left at `false`, request `/about` on your own site to see whether it answers. ### 1.2 `trailingSlash` (default `undefined`) | Value | Behavior, in Vercel's words | |---|---| | `undefined` (default) | "visiting a path with or without a trailing slash will not redirect." Both `/about` and `/about/` serve the same content. Vercel: "This is not recommended because it could lead to search engines indexing two different pages with duplicate content." | | `false` | A path ending in a slash answers 308 and redirects to the path without it: `/about/` goes to `/about`. | | `true` | A path without a slash answers 308 and redirects to the path with it: `/about` goes to `/about/`. Paths with a file extension are left alone (`/about/styles.css` does not redirect). | ### 1.3 Redirects and rewrites in `vercel.json` - A redirect is permanent by default: `permanent` defaults to `true`, which sends a **308**. `"permanent": false` sends a 307. Use `"statusCode": 301` if you want a 301 specifically. Google treats 301 and 308 the same way, as permanent. - At most **2,048** redirects in the array, and `source` and `destination` can each be up to 4,096 characters. Past that, Vercel points to middleware or bulk redirects. - The `/.well-known` path is reserved and cannot be redirected or rewritten. - For rewrites, "The `source` property should NOT be a file because precedence is given to the filesystem prior to rewrites being applied." A rewrite from a path where a file exists never fires. - With `cleanUrls` on, Vercel says not to include the file extension in rewrite sources or destinations: write `/about-our-company`, not `/about-our-company.html`. ### 1.4 Next.js projects Next.js has its own `trailingSlash` in `next.config.js`. "By default Next.js will redirect URLs with trailing slashes to their counterpart without a trailing slash. For example `/about/` will redirect to `/about`." Setting `trailingSlash: true` reverses it. With `output: 'export'`, the default writes `/about.html`, and `trailingSlash: true` writes `/about/index.html` instead. For a Next.js project, set the slash behavior in `next.config.js`, which is the setting Next.js documents. --- ## 2. Diagnose ### 2.1 What does Vercel 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 answers 308 (or 301) straight to it, and that spelling is the one in your canonical tag and sitemap. **The usual Vercel failures:** - `/about` and `/about/` both answer 200: `trailingSlash` is unset. - The canonical tag says `/about.html` but `/about.html` answers 308: `cleanUrls` is on and your templates were never updated. - Links in your pages point at `/about` but that answers 404: `cleanUrls` is off and your templates assume it is on. ### 2.2 Do your links and tags agree? 1. View the source of a live page (Ctrl+U, or Cmd+Option+U on a Mac) and find ``. 2. Request that exact address with `curl -sI`. It must answer 200. 3. Open `https://example.com/sitemap.xml` and check the page's `` the same way. 4. Check every ``. 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'); ``` This script does not request the other slash spelling of each page, so it cannot see the `trailingSlash: undefined` duplicate on its own. The probe in 2.1 does; run it on a few pages. ### 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, look at **Duplicate, Google chose different canonical than user** and **Page with redirect**, and open the examples to see which spelling Google kept. --- ## 3. Decide | Your site | Canonical form | `vercel.json` | |---|---|---| | Folder pages (`about/index.html`) | `/about/` | `"trailingSlash": true` | | Flat files, and you want clean addresses | `/about` | `"cleanUrls": true, "trailingSlash": false` | | Flat files, and you keep `.html` addresses | `/about.html` | leave `cleanUrls` off, set `"trailingSlash": false`, and check what `/about` answers | | Next.js app | your choice | `trailingSlash` in `next.config.js` (default: no slash) | 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` | --- ## 4. Fix ### Path F: folder pages, canonical `/about/` - [ ] **F1. Set the slash rule** so `/about` answers 308 to `/about/`: ```json { "$schema": "https://openapi.vercel.sh/vercel.json", "trailingSlash": true } ``` - [ ] **F2. Canonical tags end in a slash**, absolute, on every page, pointing at itself. - [ ] **F3. Sitemap `` values and hreflang alternates use the same form**, absolute, with `https` and the same host as the canonicals. - [ ] **F4. Internal links in templates, menus, footers and scripts use the folder form.** - [ ] **F5. Probe `/about/index.html`.** Vercel's docs do not say what it answers. If it is 200, the page's canonical tag covers it; never link to it. ### Path X: flat files, canonical `/about` - [ ] **X1. Turn on clean URLs and drop slashes**: ```json { "$schema": "https://openapi.vercel.sh/vercel.json", "cleanUrls": true, "trailingSlash": false } ``` Vercel then serves `about.html` at `/about`, answers `/about.html` with a 308 to `/about`, and answers `/about/` with a 308 to `/about`. - [ ] **X2. Canonical tags, sitemap and hreflang drop `.html`**: `https://example.com/about`. This is the step people miss. The host redirects `/about.html` for you, but your templates keep printing it until you change them. - [ ] **X3. Internal links drop `.html` too**, so crawlers do not pay a redirect on every link. - [ ] **X4. Remove `.html` from any rewrite rules**, as Vercel's docs require with `cleanUrls` on. ### Path H: flat files that keep `.html` - [ ] **H1. Leave `cleanUrls` off and set `"trailingSlash": false`.** - [ ] **H2. Probe `/about`.** If it answers 404, there is no second address and nothing more to do. If it answers 200, add a permanent redirect for each page: ```json { "$schema": "https://openapi.vercel.sh/vercel.json", "trailingSlash": false, "redirects": [ { "source": "/about", "destination": "/about.html", "permanent": true }, { "source": "/services", "destination": "/services.html", "permanent": true } ] } ``` Stay under 2,048 entries. Generate the list from your build output rather than typing it. - [ ] **H3. Canonical tags, sitemap, hreflang and links all keep `.html`**, absolute and identical. ### Path N: Next.js - [ ] **N1. Pick the slash behavior in `next.config.js`** (`trailingSlash: true` for `/about/`, leave it out for `/about`). - [ ] **N2. Make the canonical tag, sitemap and hreflang that your app generates use the same form**, including the slash. Check the rendered HTML, not the config. - [ ] **N3. If you use `output: 'export'`**, remember the default writes `/about.html` files; decide whether your host should serve them as `/about` (path X) or as folders (`trailingSlash: true`, path F). ### Redirects for old addresses If the old spellings were indexed, they now answer 308, which Google treats as permanent, the same as a 301. Add explicit entries only for addresses that changed for other reasons: ```json { "redirects": [ { "source": "/old-page", "destination": "/new-page", "permanent": true }, { "source": "/legacy/:slug", "destination": "/blog/:slug", "statusCode": 301 } ] } ``` --- ## 5. Deploy, then tell the crawlers - [ ] **5.1 Deploy to production.** Preview deployments are not your public site; check the production domain afterward. - [ ] **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). 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","https://example.com/about/"]}' ``` - [ ] **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. - [ ] The other slash spelling of each page answers 308 to it (`curl -sI https://example.com/about/` for path X shows `location: /about`). - [ ] `/about.html` answers 308 to `/about` (path X) or 200 (path H), never both 200 with `/about`. - [ ] 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: - Duplicates 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. - URL Inspection on each canonical address should show it 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" (IndexNow FAQ). - AI search crawlers read the HTML Vercel 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. - Vercel, static configuration with `vercel.json` (`cleanUrls`, `trailingSlash`, `redirects`, `rewrites`; last updated August 14, 2026): https://vercel.com/docs/project-configuration/vercel-json - Vercel, configuration redirects and limits: https://vercel.com/docs/routing/redirects/configuration-redirects - Next.js, `trailingSlash`: https://nextjs.org/docs/app/api-reference/config/next-config-js/trailingSlash - Next.js, static exports: https://nextjs.org/docs/app/guides/static-exports - 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/