On straffesites.com, every page has exactly one URL, and that URL has no trailing slash: /en/blog/website-without-cms, never /en/blog/website-without-cms/. The site is a static Astro 7 build (Astro 7.2.8 at the time of writing) deployed on Vercel. Setting trailingSlash: 'never' looks like a one-line decision. In practice it touches five places: the Astro config, the host, the dev server, the canonical tag, and a build check.
This article walks through each layer with the code from that project, the output from real requests, and two things I got wrong. One of them is a build check that passed on every route for two months without checking anything.
1. Astro config: what trailingSlash actually controls
// astro.config.mjs export default defineConfig({ site: 'https://straffesites.com', output: 'static', trailingSlash: 'never', }); The important detail is in the Astro configuration reference: trailingSlash sets route matching in the dev server and for on-demand rendered pages. For prerendered pages, the docs say:
Trailing slashes on prerendered pages are handled by the hosting platform, and may not respect your chosen configuration.
On a fully static site, that means the option does not redirect anything in production. It documents your intent and changes dev-server behavior. The actual redirect has to come from the host.
2. Vercel: one setting, a 308, and the query string survives
{ "framework": "astro", "outputDirectory": "dist", "trailingSlash": false } With "trailingSlash": false in vercel.json, Vercel redirects the slashed form with a permanent 308. Here is what the live site returns:
$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://straffesites.com/en/blog/website-without-cms/ 308 https://straffesites.com/en/blog/website-without-cms $ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' 'https://straffesites.com/en/blog/website-without-cms/?a=1' 308 https://straffesites.com/en/blog/website-without-cms?a=1 $ curl -s -o /dev/null -w '%{http_code}\n' https://straffesites.com/en/blog/website-without-cms 200 The query string is kept, which matters for campaign parameters.
Pitfall: the slashed redirect source costs an extra hop anyway
Google Search Console reported old 404 URLs in both forms, with and without the slash, as separate rows. So each removed URL got two entries in vercel.json:
{ "source": "/seppe-gadeyne", "destination": "/team", "permanent": true }, { "source": "/seppe-gadeyne/", "destination": "/team", "permanent": true } I expected the second entry to send /seppe-gadeyne/ straight to /team. It does not, at least not on this deployment:
$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://straffesites.com/seppe-gadeyne/ 308 https://straffesites.com/seppe-gadeyne $ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://straffesites.com/seppe-gadeyne 308 https://straffesites.com/team The trailing-slash normalization answers first, so the slashed request takes two hops and the slashed source entry is never reached. Both URLs still end on the right page, which is what matters for old links. But if you want to avoid redirect chains, measure them with curl instead of assuming that an extra source line removes a hop.
3. The dev server: a 404 where production redirects
With trailingSlash: 'never', the Astro dev server does not redirect /about/. It rejects it. In Astro 7.2.8, node_modules/astro/dist/vite-plugin-astro-server/trailing-slash.js contains:
if (trailingSlash === "never" && pathname.endsWith("/") && pathname !== "/" || trailingSlash === "always" && !pathname.endsWith("/") && !hasFileExtension(pathname)) { return { action: "reject", status: 404, pathname }; } So npm run dev showed a 404 page where production returned a 308. That gap makes local link testing misleading. The fix is a small Vite plugin that adds its own middleware and moves it in front of Astro's:
// astro.config.mjs const stripTrailingSlashDev = () => ({ name: 'strip-trailing-slash-dev', configureServer(server) { // Returning a function makes this a post hook: it runs after // Astro has installed its own dev middlewares. return () => { const handle = (req, res, next) => { const [pathname, search] = (req.url || '/').split('?'); if ( pathname !== '/' && pathname.endsWith('/') && !pathname.startsWith('/@') && !pathname.startsWith('/_') && !pathname.startsWith('/node_modules/') ) { res.statusCode = 308; res.setHeader('Location', pathname.replace(/\/+$/, '') + (search ? '?' + search : '')); res.end(); return; } next(); }; server.middlewares.use(handle); const stack = server.middlewares.stack; stack.unshift(stack.pop()); }; }, }); export default defineConfig({ // ... vite: { plugins: [stripTrailingSlashDev()] }, }); Two details matter here:
- Order. Astro's own dev plugin (
vite-plugin-astro-server/plugin.js) also uses a post hook, and in it callsviteServer.middlewares.stack.unshift(...)to put its trailing-slash handler at the front of the stack. A plainserver.middlewares.use(...)would therefore run after Astro's 404. Our plugin is listed invite.plugins, so its post hook runs after Astro's, andstack.unshift(stack.pop())moves our handler in front of everything. The trade-off:middlewares.stackbelongs to Connect, the middleware layer Vite uses, not to a documented Astro API. Recheck it after major upgrades. - Exclusions. Paths starting with
/@(Vite modules),/_(Astro internals) and/node_modules/are left alone, so HMR and module requests keep working.
The dev server now behaves like production, query string included:
$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://localhost:4391/en/blog/website-without-cms/ 308 http://localhost:4391/en/blog/website-without-cms $ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' 'http://localhost:4391/en/blog/website-without-cms/?a=1' 308 http://localhost:4391/en/blog/website-without-cms?a=1 4. Canonical tags: Astro.url has a slash during the build
Even with trailingSlash: 'never', a canonical built from Astro.url can come out with a trailing slash. The default build.format is 'directory', which writes dist/en/blog/website-without-cms/index.html. The build.format docs explain the effect:
directory- TheAstro.url.pathnamewill include a trailing slash to mimic folder behavior. (e.g./foo/)
So the base layout normalizes the path itself before it writes canonical and og:url:
--- // src/layouts/BaseLayout.astro const canonicalPath = Astro.url.pathname.replace(/\/+$/, '') || '/'; const canonicalUrl = new URL(canonicalPath, Astro.site).href; --- <link rel="canonical" href={canonicalUrl} /> <meta property="og:url" content={canonicalUrl} /> For the homepage, the regex leaves an empty string; || '/' keeps that path explicit, so the canonical is https://straffesites.com/.
5. The build check that could never fail
The last layer is a check that runs after astro build. It reads every generated HTML file and fails when an internal href (or a content attribute, which covers og:url) ends in a slash. Here is the version that went in at the end of August (check labels translated from Dutch):
const slashedHrefs = matches(html, /(?:href|content)="\/(?!\/)[^"]*\/(?!.)"/g) .filter((href) => !href.startsWith('/assets/')); check(slashedHrefs.length === 0, `${path}: no internal hrefs with a trailing slash`); It printed ok on every route, every build. While preparing this article, I tested it on a known bad input:
$ node -e 'console.log("<a href=\"/blog/x/\">".match(/(?:href|content)="\/(?!\/)[^"]*\/(?!.)"/g))' null There were two bugs:
-
\/(?!.)"asks for a slash that is not followed by any character, and then for a"right after it. A quote is also a character, so the pattern can never match. I meant "slash at the end of the attribute value," and the closing quote already expresses that. - The helper
matches()returnsmatch[1], but the pattern had no capture group. Even a matching pattern would have producedundefined.
The fix adds a capture group, drops the lookahead, and adds a self-test that proves the pattern can catch the error it exists for:
const slashedHrefPattern = /(?:href|content)="(\/(?!\/)[^"]*\/)"/g; check( matches('<a href="/blog/x/">', slashedHrefPattern).join() === '/blog/x/' && matches('<a href="/blog/x"><a href="/"><a href="//cdn.example/">', slashedHrefPattern).length === 0, 'trailing-slash check catches a slashed href and ignores / and protocol-relative URLs (self-test)', ); (?!\/) after the first slash skips protocol-relative URLs such as //cdn.example/. The root / cannot match, because the pattern needs a second slash. The /assets/ filter remains a separate step.
Before committing the fix, I ran the corrected pattern over the existing build output: 246 HTML files, 0 violations. The convention had held because the other layers worked, not because the check guarded it. The full npm run check passed after the fix, including the new self-test.
The general lesson is not about trailing slashes. A check that has never failed may simply be unable to fail. For each regex-based output check, keep one known bad sample next to it and assert that the pattern finds it.
Summary
| Layer | Setting or code | What it handles |
|---|---|---|
| Astro config | trailingSlash: 'never' | Intent, dev routing, on-demand pages; not static redirects |
| Vercel | "trailingSlash": false | 308 from /path/ to /path, query string kept |
| Redirects | source with and without slash | Old URLs in both forms; the slashed form still takes two hops |
| Dev server | Vite post-hook middleware | 308 instead of Astro's 404 locally |
| Canonical | Strip slash from Astro.url.pathname | build.format: 'directory' adds one during the build |
| Build check | Regex plus self-test | Fails the build on any slashed internal href |
The convention and these checks are part of the build for straffesites.com. For the setup this builds on, see Set up Astro with Svelte, Tailwind CSS, Vercel, Prettier, and TypeScript.

