Skip to main content
ASoc
Tutorial

Next.js Trailing Slash: The Config Isn't Your Problem, the Env Var Is

344 prerendered routes, zero trailing slashes, and no trailingSlash config at all. The bug that did reach production came from one env var, and the one-line fix it now has.

The ASoc Team10 min read

Next.js resolves trailing slashes with a 308 redirect, and by default it redirects /about/ to /about. trailingSlash: true inverts that. The setting is one line and almost nobody's real problem: the URLs Next controls are already consistent. The ones that break are the absolute URLs you build yourself, and this codebase shipped that bug to production.

This build has zero trailingSlash entries in next.config.ts, 344 prerendered routes, and not one of them ends in a slash. It also has a six-case unit test guarding a single .replace(/\/+$/, ""), because a trailing slash in one environment variable put doubled slashes into the JSON-LD Google was crawling.

What the setting actually does

Config/about/about/Where the redirect happens
unset (default false)200308 → /aboutServer, before your code
trailingSlash: true308 → /about/200Server, before your code
skipTrailingSlashRedirect: true200200 (your problem)Nowhere — you own it

Three things about that table are worth more than the setting itself.

It is a 308, not a 301. Permanent and method-preserving: a POST to /api/thing/ is re-issued as a POST, where a 301 would historically have been downgraded to GET. That matters if anything ever posts to a path a human typed.

Static file paths are exempt. With trailingSlash: true, anything with an extension and anything under /.well-known/ is left alone — /file.txt does not become /file.txt/. This is in the official config docs and it is the reason enabling the flag does not break your robots.txt.

The redirect costs a round trip. It is a real HTTP response, served before any page render. On a /about/ link that a human pasted into an email, every visitor pays a redirect hop before the first byte of HTML. That is a reason to make your own links consistent, not a reason to change the flag.

The audit: what this codebase actually emits

The claim "our URLs are consistent" is checkable, so we checked it rather than asserting it.

# Every internal href in the app and component trees.
$ grep -rno 'href="' src/components src/app | wc -l
72

# ...of which, how many end in a slash (root excluded)?
$ grep -rnoP 'href="/[^"]*/"' src/ | wc -l
0

And from .next/prerender-manifest.json after a clean npm run build:

$ node -e "const m=require('./.next/prerender-manifest.json');
  const k=Object.keys(m.routes);
  console.log(k.length, 'routes;',
    k.filter(x => x !== '/' && x.endsWith('/')).length, 'with a trailing slash');"
344 routes; 0 with a trailing slash

344 prerendered routes — 111 product pages, 104 blog posts and their OG images, 7 category hubs, the static marketing pages — and the only URL ending in a slash is / itself, which is the origin, not a trailing slash. The sitemap agrees by construction, because all 14 of its URL templates in src/app/sitemap.ts are built the same way:

url: `${BASE_URL}/templates/${product.slug}`,

So does every canonical, declared per route as a root-relative path that Next resolves against metadataBase:

// src/app/pricing/page.tsx
alternates: { canonical: "/pricing" },

None of that consistency comes from the trailingSlash config. It comes from every one of those strings being written without a slash and never being concatenated with a value that ends in one. Which is exactly where it went wrong.

The bug we actually shipped: BASE_URL had the slash

BASE_URL is siteUrl(), and siteUrl() reads an environment variable. Set NEXT_PUBLIC_SITE_URL=https://asoctemplates.com/ — the form a hosting dashboard hands you when you copy the domain, and the form a human types by reflex — and every template literal above produces a doubled slash:

https://asoctemplates.com//templates/asoc-admin
https://asoctemplates.com//images/asoc-admin-cover.jpg

//path is a valid URL and the browser serves the page, which is why this survives a click-through test. What it breaks is everything that compares URLs as strings:

  • JSON-LD stopped matching the canonical. The Product schema's url and image values are built from BASE_URL; the canonical tag is built by Next from metadataBase. A doubled slash in one and not the other means the structured data describes a URL that is not the page's canonical URL. That is how we found it.
  • Supabase auth redirects were rejected outright. The redirect allow-list is an exact string match. https://host//auth/callback is not https://host/auth/callback, so the redirect fails — not degraded, refused.

The fix is one line, and it lives in exactly one place on purpose:

// src/lib/siteUrl.ts
export function siteUrl(fallback = "https://asoctemplates.com"): string {
  return (process.env.NEXT_PUBLIC_SITE_URL || fallback).replace(/\/+$/, "");
}

Two details in there are the actual lessons. \/+$ strips repeated slashes, because https://host/// is a thing people paste. And the fallback uses || rather than ??, so an empty-string env var — which ?? would happily accept — is treated as unset; an empty BASE_URL would have produced root-relative strings in the places that require absolute URLs, converting a cosmetic bug into a silent one.

It is guarded by six cases in src/lib/__tests__/siteUrl.test.ts, including the one that documents why the helper exists at all:

it("strips a trailing slash so `${siteUrl()}/path` never doubles it", () => {
  process.env.NEXT_PUBLIC_SITE_URL = "https://asoctemplates.com/";
  expect(siteUrl()).toBe("https://asoctemplates.com");
  expect(`${siteUrl()}/images/x.jpg`).toBe(
    "https://asoctemplates.com/images/x.jpg",
  );
});

The reason the helper is centralised is the same reason it was needed: the strip had previously been copy-pasted into some call sites and missed in others, and the ones it was missed in were the ones that reached production JSON-LD.

Trailing slashes and the proxy

Next 16 runs src/proxy.ts — the file that used to be middleware.ts, renamed in 16 — on every matched request, and this app uses it to refresh the Supabase session. Two flags exist for the interaction, and both are worth knowing about before you need them:

  • skipTrailingSlashRedirect: true turns the automatic 308 off entirely so you can implement the policy yourself in the proxy. Useful if you need per-path rules, which the config flag cannot express.
  • skipMiddlewareUrlNormalize: true hands the proxy the raw, un-normalised URL, so you can see what the client actually asked for rather than the tidied version.

We set neither. The matcher is a negative lookahead over static assets and crawler files:

matcher: [
  "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.(?:svg|png|jpg|jpeg|webp|gif|ico|webmanifest|html)$).*)",
],

The thing to notice is that this pattern is written against normalised paths. If you turn on skipMiddlewareUrlNormalize, matchers like this one start seeing both /pricing and /pricing/ as distinct strings, and a negative lookahead written for one form silently stops covering the other. That is a self-inflicted wound available only to people who enable the flag, which is a decent argument for leaving it off.

When trailingSlash: true is the right call

Rarely, but not never:

SituationWhy the slash helps
Migrating a site whose old URLs all had slashesPreserves link equity without a redirect map — but see below
A static export served by a host that maps /about//about/index.htmlMatches the host's own directory semantics
An existing corpus of backlinks and printed URLs using slashesAvoids a redirect hop on every inbound click

Note what is not on that list: SEO. Google has said for years that it treats /about and /about/ as different URLs but handles either consistently as long as you pick one and canonicalise to it. There is no ranking difference between the two forms. The ranking damage comes from serving both with 200s and no canonical, which is what skipTrailingSlashRedirect lets you do by accident.

The migration row also has a catch worth stating: if your old URLs had slashes and you flip the flag, your new internal links, sitemap and canonicals must all move to the slash form together. A half-migrated site is worse than either whole one, because now the sitemap lists URLs that 308 to somewhere else — you are asking a crawler to spend budget discovering redirects.

Common mistakes

MistakeSymptomFix
Trailing slash in NEXT_PUBLIC_SITE_URLhttps://host//path in JSON-LD, OG tags, auth redirectsStrip it once in a helper; test the helper
Stripping the slash at some call sites, not allInconsistent absolute URLs; canonical/JSON-LD mismatchOne siteUrl(), imported everywhere
?? instead of `` on the env fallback
Sitemap URLs in one form, canonicals in the otherCrawler spends budget on 308sBuild both from the same helper
skipTrailingSlashRedirect without owning the policyBoth forms serve 200; duplicate content, split signalsImplement the redirect in the proxy, or don't skip
Flipping trailingSlash mid-life without moving linksEvery internal click pays a redirect hopMove config, links, sitemap and canonicals in one change
Assuming a static host redirects for you404s on the form your host does not synthesiseCheck the host — a no-compute host cannot 308 at all

That last row is a real constraint rather than a hypothetical: a host with no compute layer cannot issue the 308 at all, which is one of the differences we counted between Vercel and GitHub Pages.

Frequently asked questions

Does trailingSlash affect API routes? Yes — route handlers are routes, and the same 308 applies. This is where the 308-versus-301 distinction earns its keep: a POST to /api/webhooks/lemonsqueezy/ is redirected with its method and body intact. It still costs a round trip, and a webhook provider retrying on a redirect is a class of problem you do not want, so register the exact URL with the provider rather than relying on the redirect.

Do I need a redirects() entry to normalise slashes? No. The 308 is built in and runs before your config's redirects. Adding a rule for it is redundant, and a redirects() entry that fights the built-in behaviour produces a loop. Our own next.config.ts has no redirects() block at all — the thirteen redirects in this app are all redirect() calls that depend on auth state the config cannot see.

Will Google penalise me for having both forms? Not penalise — split. Two URLs serving the same 200 response are two pages competing with each other unless a canonical tells Google which one counts. The default 308 makes that impossible by construction, which is the strongest argument for leaving the setting alone.

How do I check my own build for this? Two commands, both above: grep -rnoP 'href="/[^"]*/"' src/ for internal links, and the prerender-manifest.json one-liner for generated routes. Then set NEXT_PUBLIC_SITE_URL with a trailing slash locally and grep the built HTML for // — if the doubled slash appears anywhere outside https://, you have the bug this post is about.

Templates where these URL conventions ship

ASoc Clover is a project-and-task admin with enough nested routes that URL consistency stops being theoretical. ASoc Crest spans 5 dashboards and 8 app modules on the same conventions. ASoc Estate pairs a property back-office with public-facing detail pages, which is where canonical and JSON-LD URLs have to agree exactly.

Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates. For the routing layer around this, read three redirect APIs and why we use two and rewrites: three phases and the four we turned down.

Keep reading

Tutorial7 min read

Next.js Turbopack: 13.9s vs. webpack's 18.3s, Same Commit

A measured head-to-head on 538 pages, plus the config trap: Turbopack serializes options to Rust, so an imported MDX plugin now fails the build outright.

Read more
Tutorial8 min read

The Latest Next.js Version Is 16.3.4. This Repo Runs 16.2.9.

npm carries sixteen dist-tags for `next` and `latest` is only one of them. Which version to be on, measured from a repo that pins the framework and ships it to other people.

Read more