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.
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) | 200 | 308 → /about | Server, before your code |
trailingSlash: true | 308 → /about/ | 200 | Server, before your code |
skipTrailingSlashRedirect: true | 200 | 200 (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
Productschema'surlandimagevalues are built fromBASE_URL; the canonical tag is built by Next frommetadataBase. 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/callbackis nothttps://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: trueturns 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: truehands 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:
| Situation | Why the slash helps |
|---|---|
| Migrating a site whose old URLs all had slashes | Preserves link equity without a redirect map — but see below |
A static export served by a host that maps /about/ → /about/index.html | Matches the host's own directory semantics |
| An existing corpus of backlinks and printed URLs using slashes | Avoids 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
| Mistake | Symptom | Fix |
|---|---|---|
Trailing slash in NEXT_PUBLIC_SITE_URL | https://host//path in JSON-LD, OG tags, auth redirects | Strip it once in a helper; test the helper |
| Stripping the slash at some call sites, not all | Inconsistent absolute URLs; canonical/JSON-LD mismatch | One siteUrl(), imported everywhere |
?? instead of ` | ` on the env fallback | |
| Sitemap URLs in one form, canonicals in the other | Crawler spends budget on 308s | Build both from the same helper |
skipTrailingSlashRedirect without owning the policy | Both forms serve 200; duplicate content, split signals | Implement the redirect in the proxy, or don't skip |
Flipping trailingSlash mid-life without moving links | Every internal click pays a redirect hop | Move config, links, sitemap and canonicals in one change |
| Assuming a static host redirects for you | 404s on the form your host does not synthesise | Check 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.
