Fix Trailing Slash Canonical Mismatches in Next.js on Vercel

On September 7, 2026 a one-line commit to hsvagi.com triggered a Vercel build from git that removed trailingSlash: true from production. Six days later my own crawler found 164 canonical mismatches and 121 sitemap entries answering with a redirect.

The setting had been live since July 19, but only in deployments made from my laptop with uncommitted changes. Git never had it. This post covers the slash itself, how I proved what reverted it, and the check that stops it happening twice.

What did the crawler find on September 13?

The crawler is a 157-line Python script in the repo, scripts/audit-seo.py, using only the standard library. It reads the sitemap, follows internal links, and does not follow redirects. For every page that returns 200, it compares the canonical tag with the URL the page was actually served at.

I ran it against production at 22:17 UTC on September 13, then again at 01:18 UTC on September 14, after the fix went out. Both reports are saved in docs/seo/.

MeasureSep 13 (before)Sep 14 (after)
Sitemap entries122132
URLs crawled316329
Canonical mismatches1640
Sitemap entries not returning 2001210
Served pages missing from the sitemap1640
Broken URLs (404)170
Broken image assets30
Total problems3050

I sorted all 164 mismatches. Every one differed from the served URL by a trailing slash and nothing else. On 163 pages the canonical said /about/ and the page was served at /about. The 164th was the homepage, where the canonical omitted the slash after the domain. Those two homepage forms are the same URL, so that one is my crawler being strict. The real count is 163.

The damage was sitewide, not one template. The guides section had 36 affected pages, the launch pages 35, services 25 and industries 16. A third-party audit export from the same afternoon agreed: 163 pages with a canonical pointing at a redirect, and 120 redirecting sitemap URLs. My 121 counts one guide URL that the sitemap listed twice.

Why does one slash send Google two opposite signals?

Because Next.js and the code disagreed about which URL was real. The Next.js trailingSlash docs say: “By default Next.js will redirect URLs with trailing slashes to their counterpart without a trailing slash. For example /about/ will redirect to /about.”

Since the first build in December 2025, every canonical, internal link and sitemap entry on hsvagi.com has used the slashed form. Without the setting, the server sent each of those slashed URLs to the unslashed one with a 308.

Google’s canonicalization guide calls a redirect “a strong signal that the target of the redirect should become canonical.” It calls rel="canonical" “a strong signal that the specified URL should become canonical.” It also says: “Don’t specify different URLs as canonical for the same page using different canonicalization techniques.”

So on 163 pages, two strong signals pointed in opposite directions. The sitemap, which Google calls “a weak signal,” sided with the canonical tag. Google’s sitemap guide says to “include the URLs in your sitemap that you want to see in Google’s search results.” Every URL I listed answered with a redirect.

Google’s guide does not say which signal wins in that conflict. That choice is Google’s. What you control is making sure the conflict never exists: the served URL, the canonical, the sitemap entry and every internal link must match character for character, slash included.

How did a fix that was already live disappear?

The fix only ever existed in my working tree. I pulled the production deployment history for the project from the Vercel API and lined it up against git.

Production deploy (UTC)SourceCommit labelUncommitted changestrailingSlash
Apr 7, 2026gitb3cdbf2NoOff
Jul 19 and Jul 21 (three deploys)CLIb3cdbf2YesOn
Sep 7, 22:11gite1dd16aNoOff
Sep 13, 22:34CLIe1dd16aYesOn
Oct 10, 20:11git73754b3NoOn

In July I deployed to production from the command line three times. Those deployments carried the slash setting, retired-URL redirects and robots changes that were never committed. The last July deploy differed from its labelled commit in 11 source files and carried 3 more files that git did not have.

On September 7 I fixed a different bug, the root-layout canonical. That commit changed one line in one file, and I pushed it. Vercel’s git docs say “A Production deployment will be created each time you merge to the production branch.” That build came from the repository, and the repository had never contained trailingSlash: true.

Vercel’s deployment docs say the CLI method “works whether your project is connected to Git or not.” Read together, the two pages describe a trap neither one states. A CLI deploy from a dirty tree creates a production site that exists nowhere in git, and the next push to main replaces it without warning.

The crawl confirms which build it measured. The image URLs in the September 13 report carry the deployment ID of the September 7 git build. The slash setting was live for 49 days, gone for 6, then live again but outside git for another 26, until I committed the exact deployed tree on October 10.

Doesn’t the Vercel dashboard show which commit is live?

It shows a commit, and for a CLI deploy that commit can be wrong about the code. This is the assumption that let the drift survive two months. The September 13 deployment was labelled e1dd16a, the same as the git build it replaced.

I checked the label against the files. Vercel’s API lists each uploaded file with a SHA-1 of its contents, so I hashed the same paths at each commit and compared. The September 13 deployment differed from e1dd16a in 21 source files and carried 6 files that commit did not contain. Against 73754b3, the commit I made on October 10, it matched in every file except two that the build generates.

The only marker is in the deployment metadata: gitDirty: "1". In the last eight production deploys, every CLI deploy has it and no git deploy does. Here is the read-only check I now run:

$ vercel api "/v6/deployments?projectId=<project-id>&target=production&limit=10"
# For each deployment, read:
#   source            "git" or "cli"
#   meta.gitDirty     "1" means uncommitted files went to production

If the newest production deployment says cli and gitDirty, your repo is behind your live site. The next push will roll production back to whatever git has.

What does the site serve today?

On October 10 I fetched every URL in the live sitemap. All 132 return 200, and all 132 carry a canonical that matches their own address exactly. None of the 132 lacks the trailing slash, and none is listed twice.

The 17 broken URLs from September now redirect: 11 retired service-and-city pages go to their service page, two old industry slugs go to the current ones, and four legacy service slugs keep their city. Next.js sends these as 308s. Google’s redirect guide says “The 301 and 308 status codes mean that a page has permanently moved.”

$ curl -sIL https://www.hsvagi.com/services/lead-intake-systems/huntsville-al | grep -iE "^HTTP|^location"
HTTP/2 308
location: /services/lead-intake-systems/huntsville-al/
HTTP/2 308
location: /services/ai-lead-intake-systems/huntsville-al/
HTTP/2 200

That is two hops: the slash first, then the move. Old /blog/ URLs took three, because the redirect’s destination, /ai-guides/:slug, had no trailing slash, so Next.js added a third hop. Google’s HTTP status code guide says its crawlers “follow up to 10 redirect hops.” Three is inside that. It was still one hop more than it needed. On October 10 I added the slash to that destination, and an old /blog/ post URL with its slash now lands in one hop.

Are the private pages kept out of the index?

Half of it works. The same fix made /portal/, /admin/, /dashboard/ and /account/ send noindex, nofollow, and I confirmed all four live on October 10. It also added all four to robots.txt as disallowed.

Those two measures cancel out for Google. Its noindex guide says the page “must not be blocked by a robots.txt file,” because otherwise “the crawler will never see the noindex rule” and “the page can still appear in search results.” The stakes here are low: these are login screens. On October 10 I dropped the four disallow lines, so the noindex can do its job. /api/ stays disallowed.

What did the six days cost in search?

I don’t know yet. I have no Search Console data for this period in the files I worked from, and I am not going to estimate it. I also have no crawl from the July to September window, so the 49 clean days are what the deployed config implies, not something I measured.

How do I stop this on my own Next.js site?

  1. Run curl -sI on one sitemap URL. Anything other than 200 means your sitemap suggests URLs you do not serve.
  2. View the source of that page. The canonical must equal the address bar after redirects, slash included.
  3. If your canonicals end in a slash, set trailingSlash: true in next.config. If they don’t, remove the slash from the canonicals. Pick one convention and write it down.
  4. Write every redirect destination in that same convention, so a move costs one hop and not two.
  5. Run git status before any CLI production deploy. If it is not clean, commit first.
  6. If you hot-fix from the CLI anyway, commit that exact tree the same day, before anyone pushes anything else.
  7. After each deploy, check the newest production deployment for source: "cli" with gitDirty: "1".
  8. Rerun your crawler after every production deploy and compare the problem counts to the last run.
  9. Never block in robots.txt a page whose job is to say noindex.

The same signal conflict shows up in two other rebuilds: fixing canonical signals after a www flip, where 53 pages carried two canonical tags, and fixing canonicals that point at redirects on an expired domain rebuild.

Sources

Rob Boirun
Rob Boirun

Rob Boirun is the founder of PopNet Media and has worked in web and search for 25 years. He writes SEO Lab's case studies from sites he builds and runs with Claude Code.

We will be happy to hear your thoughts

Leave a reply

SEO Lab
Logo