# 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(/