You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Note two inconsistencies that no trailingSlash semantics can explain:
omitting path vs passing '' produce different URLs (/pl vs /pl/);
getAbsoluteLocaleUrlList('') — “a list of absolute paths for all the locales”, the shape an hreflang block needs — is inconsistent within a single call: the default locale gets no slash, every other locale gets one.
This is a re-file of #14140 (same bug, closed as stale on no activity), where @matthewp wrote:
If you're still experiencing this in Astro 6, please open a new issue and we'll take a fresh look.
It reproduces on Astro 6.4.5. Previously reported in #9919, #11630, #13032 and #14140; #13045 fixed only the case where the join collapses to "" (default locale, no base), not the locale-prefixed one.
Why projects end up here: with 'always' or the default 'ignore' + build.format: 'directory', these helpers append a slash to every URL, so an on-demand-rendered site that wants slash-less URLs has exactly one option — trailingSlash: 'never' — and then hits this bug on its language switcher and hreflang alternates.
Astro itself rejects the URLs it generates
The same config that produces these links refuses to serve them — both observable in the reproduction (npm test asserts the SSR behavior, npm run dev shows the dev notice):
dev: GET /pl/ → 404 with the notice “Your site is configured with trailingSlash set to never. Do you want to go to /pl instead?” (GET /pl → 200)
on-demand rendering (@astrojs/node): GET /pl/ → 301 redirect to /pl (308 for non-GET) via TrailingSlashHandler (packages/astro/src/core/routing/trailing-slash-handler.ts)
The affected URLs are the most-linked pages of a localized site — every language switcher and every hreflang block includes each locale’s home page. Concretely:
An extra round trip on every affected navigation. Each click on a generated home link costs a 301 plus a second request before any HTML arrives — the hop Lighthouse’s “Avoid multiple page redirects” audit penalizes. On serverless/edge deployments each such hit is an extra billed invocation, and access logs and edge analytics fill with self-inflicted 301s for plain internal navigation.
SEO. hreflang/canonical alternates built from getAbsoluteLocaleUrlList() send crawlers to URLs that 301: Search Console files the targets under “Page with redirect”, crawl budget is spent on the hop, and the alternate URLs contradict the URLs @astrojs/sitemap emits for the same pages — the sitemap integration explicitly honors trailingSlash: 'never' (write-sitemap.js checks astroConfig.trailingSlash === "never"), so two official Astro packages currently disagree about the canonical form of the same page.
Dev. The very first language-switcher link built with the documented API responds 404 (notice page above).
Root cause
getLocaleRelativeUrl (packages/astro/src/i18n/index.ts) keeps the empty path as a join segment, and the trailingSlash: 'never' branch only refrains from appending a slash — it never strips one:
constpathsToJoin=[base,prependWith];// …pushes normalizedLocale for non-default locales…pathsToJoin.push(path);// path === '' is keptletrelativePath;if(shouldAppendForwardSlash(trailingSlash,format)){relativePath=appendForwardSlash(joinPaths(...pathsToJoin));}else{relativePath=joinPaths(...pathsToJoin);// ← no stripping here}
joinPaths (@astrojs/internal-helpers) filters only non-strings, so '' survives and ['', 'pl', ''].join('/') ends with /:
The prependWith case is a second mechanism in the same function: i === paths.length - 1 compares the filtered index against the unfiltered rest-parameter length. When prependWith is undefined the last-element branch never fires and trimSlashes accidentally cleans trailing slashes from the input path; when prependWith is a string, the branch fires and a trailing slash in path survives into the output.
getLocaleAbsoluteUrl and the *List variants delegate to the same logic, so they inherit all of the above.
“But the docs example shows /fr/”
The current docs do show getAbsoluteLocaleUrl("fr", ""); // returns https://example.com/fr/ — the implementation matches that line. The point of this issue is that the line documents the bug rather than a contract:
The example set is self-contradictory. The same section still shows getRelativeLocaleUrl("fr"); // returns /fr next to getRelativeLocaleUrl("fr", ""); // returns /fr/. No implementation that honors a single trailingSlash setting can produce both — only one that treats undefined and '' differently, i.e. the bug. The examples also state no config, yet under the default config ('ignore' + build.format: 'directory') the actual output of getRelativeLocaleUrl("fr") is /fr/, so they don’t describe defaults either. The inline JSDoc shipped with Astro likewise shows getRelativeLocaleUrl("es"); // /es.
The operative contract says trailing slashes are config-driven. The same reference page instructs:
When creating routes with these functions, be sure to take into account your individual settings for: base, trailingSlash, build.format, site
Only match URLs that do not include a trailing slash (e.g: “/about”). In production, requests for on-demand rendered URLs with a trailing slash will be redirected to the correct URL for your convenience.
The runtime (TrailingSlashHandler) enforces exactly that — against the helpers’ own output.
“Just call the function without the second argument”
In #13032@ematipico suggested omitting the argument instead of passing ''. The core problem with that: in real code path is a computed value, not a literal. The signature is
and a language switcher or hreflang loop derives the current page’s path from Astro.url.pathname, holding a string — which is '' on the home page:
---const path =stripLocale(Astro.url.pathname); // '' on the home page, 'docs/setup' elsewhere---{locales.map((l) => <ahref={getRelativeLocaleUrl(l, path)}>{l}</a>)}
There is no string value of path that yields the correct home URL — the only escape is getRelativeLocaleUrl(l, path || undefined), abusing argument optionality to change the output for the same logical input. The parameter’s own JSDoc reads “An optional path to add after the locale” — adding nothing after the locale should be equivalent to omitting it.
Additionally:
'/' is an unambiguously valid root path and is equally affected;
the official docs example itself passes "";
omitting is not even possible for getAbsoluteLocaleUrlList(path), whose whole purpose is mapping one path across all locales — the shape an hreflang block needs.
Suggested fix
In the else branch of both getLocaleRelativeUrl and getLocaleAbsoluteUrl:
The existing if (relativePath === "") return "/"; guard (from #13045) keeps the root case correct. This one-liner fixes every case above, including the prependWith one and the *List variants (they delegate). A narrower alternative — dropping empty segments in joinPaths (filter((p) => isString(p) && p !== '')) — would fix only the empty/root-path class, not the prependWith trailing-slash class.
What's the expected result?
With trailingSlash: 'never', no URL generated by astro:i18n ends with a trailing slash — the same invariant TrailingSlashHandler enforces for incoming requests:
output: 'server' with @astrojs/node (standalone), trailingSlash: 'never', two locales; every URL exercised by the tests exists as a real page. npm install && npm test builds the site, starts the built server, and asserts over HTTP in two sections: URL generation (the seven calls above — five fail) and server enforcement (these pass: the slash-less URLs the helpers should return respond 200 — /pl, /pl/docs/setup, /blog/pl/docs/setup — while the URLs they actually return are 301-redirected — /pl/, /blog/pl/docs/setup/), demonstrating that Astro redirects away from the URLs its own helpers generate. npm run dev shows the same via the language-switcher link → 404 notice page.
Participation
I am willing to submit a pull request for this issue.
Astro Info
If this issue only occurs in one browser, which browser is a problem?
No response
Describe the Bug
With
trailingSlash: 'never', theastro:i18nURL helpers return URLs with a trailing slash in three situations:Note two inconsistencies that no
trailingSlashsemantics can explain:pathvs passing''produce different URLs (/plvs/pl/);getAbsoluteLocaleUrlList('')— “a list of absolute paths for all the locales”, the shape an hreflang block needs — is inconsistent within a single call: the default locale gets no slash, every other locale gets one.This is a re-file of #14140 (same bug, closed as stale on no activity), where @matthewp wrote:
It reproduces on Astro 6.4.5. Previously reported in #9919, #11630, #13032 and #14140; #13045 fixed only the case where the join collapses to
""(default locale, no base), not the locale-prefixed one.Why projects end up here: with
'always'or the default'ignore'+build.format: 'directory', these helpers append a slash to every URL, so an on-demand-rendered site that wants slash-less URLs has exactly one option —trailingSlash: 'never'— and then hits this bug on its language switcher and hreflang alternates.Astro itself rejects the URLs it generates
The same config that produces these links refuses to serve them — both observable in the reproduction (
npm testasserts the SSR behavior,npm run devshows the dev notice):GET /pl/→ 404 with the notice “Your site is configured withtrailingSlashset tonever. Do you want to go to/plinstead?” (GET /pl→ 200)@astrojs/node):GET /pl/→ 301 redirect to/pl(308 for non-GET) viaTrailingSlashHandler(packages/astro/src/core/routing/trailing-slash-handler.ts)The affected URLs are the most-linked pages of a localized site — every language switcher and every hreflang block includes each locale’s home page. Concretely:
getAbsoluteLocaleUrlList()send crawlers to URLs that 301: Search Console files the targets under “Page with redirect”, crawl budget is spent on the hop, and the alternate URLs contradict the URLs@astrojs/sitemapemits for the same pages — the sitemap integration explicitly honorstrailingSlash: 'never'(write-sitemap.jschecksastroConfig.trailingSlash === "never"), so two official Astro packages currently disagree about the canonical form of the same page.Root cause
getLocaleRelativeUrl(packages/astro/src/i18n/index.ts) keeps the empty path as a join segment, and thetrailingSlash: 'never'branch only refrains from appending a slash — it never strips one:joinPaths(@astrojs/internal-helpers) filters only non-strings, so''survives and['', 'pl', ''].join('/')ends with/:The
prependWithcase is a second mechanism in the same function:i === paths.length - 1compares the filtered index against the unfiltered rest-parameter length. WhenprependWithisundefinedthe last-element branch never fires andtrimSlashesaccidentally cleans trailing slashes from the input path; whenprependWithis a string, the branch fires and a trailing slash inpathsurvives into the output.getLocaleAbsoluteUrland the*Listvariants delegate to the same logic, so they inherit all of the above.“But the docs example shows
/fr/”The current docs do show
getAbsoluteLocaleUrl("fr", ""); // returns https://example.com/fr/— the implementation matches that line. The point of this issue is that the line documents the bug rather than a contract:The docs originally promised the slash-less output. Until January 2025 the
astro:i18nreference read// returns /frand// returns https://example.com/frfor these exact calls. fix: outputs and type inastro-i18ndocs#10797 (merged 2025-01-27, days aftergetRelativeLocaleUrlapends trailing slash even withtrailingSlash: 'never'#13032 was closed as “expected”) flipped the documented outputs to match the implementation:The example set is self-contradictory. The same section still shows
getRelativeLocaleUrl("fr"); // returns /frnext togetRelativeLocaleUrl("fr", ""); // returns /fr/. No implementation that honors a singletrailingSlashsetting can produce both — only one that treatsundefinedand''differently, i.e. the bug. The examples also state no config, yet under the default config ('ignore'+build.format: 'directory') the actual output ofgetRelativeLocaleUrl("fr")is/fr/, so they don’t describe defaults either. The inline JSDoc shipped with Astro likewise showsgetRelativeLocaleUrl("es"); // /es.The operative contract says trailing slashes are config-driven. The same reference page instructs:
and the
trailingSlash: 'never'reference:The runtime (
TrailingSlashHandler) enforces exactly that — against the helpers’ own output.“Just call the function without the second argument”
In #13032 @ematipico suggested omitting the argument instead of passing
''. The core problem with that: in real codepathis a computed value, not a literal. The signature isand a language switcher or hreflang loop derives the current page’s path from
Astro.url.pathname, holding astring— which is''on the home page:There is no string value of
paththat yields the correct home URL — the only escape isgetRelativeLocaleUrl(l, path || undefined), abusing argument optionality to change the output for the same logical input. The parameter’s own JSDoc reads “An optional path to add after thelocale” — adding nothing after the locale should be equivalent to omitting it.Additionally:
'/'is an unambiguously valid root path and is equally affected;"";getAbsoluteLocaleUrlList(path), whose whole purpose is mapping one path across all locales — the shape an hreflang block needs.Suggested fix
In the
elsebranch of bothgetLocaleRelativeUrlandgetLocaleAbsoluteUrl:The existing
if (relativePath === "") return "/";guard (from #13045) keeps the root case correct. This one-liner fixes every case above, including theprependWithone and the*Listvariants (they delegate). A narrower alternative — dropping empty segments injoinPaths(filter((p) => isString(p) && p !== '')) — would fix only the empty/root-path class, not theprependWithtrailing-slash class.What's the expected result?
With
trailingSlash: 'never', no URL generated byastro:i18nends with a trailing slash — the same invariantTrailingSlashHandlerenforces for incoming requests:…and
getRelativeLocaleUrl(locale)≡getRelativeLocaleUrl(locale, '')regardless of configuration.Link to Minimal Reproducible Example
https://github.com/iseraph-dev/astro-i18n-trailingslash-repro
output: 'server'with@astrojs/node(standalone),trailingSlash: 'never', two locales; every URL exercised by the tests exists as a real page.npm install && npm testbuilds the site, starts the built server, and asserts over HTTP in two sections: URL generation (the seven calls above — five fail) and server enforcement (these pass: the slash-less URLs the helpers should return respond 200 —/pl,/pl/docs/setup,/blog/pl/docs/setup— while the URLs they actually return are 301-redirected —/pl/,/blog/pl/docs/setup/), demonstrating that Astro redirects away from the URLs its own helpers generate.npm run devshows the same via the language-switcher link → 404 notice page.Participation