DEV Community

Cover image for Trailing slashes in a static Astro site on Vercel: five layers and a check that never failed
Seppe Gadeyne
Seppe Gadeyne

Posted on

Trailing slashes in a static Astro site on Vercel: five layers and a check that never failed

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',
});
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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()] },
});
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  • Order. Astro's own dev plugin (vite-plugin-astro-server/plugin.js) also uses a post hook, and in it calls viteServer.middlewares.stack.unshift(...) to put its trailing-slash handler at the front of the stack. A plain server.middlewares.use(...) would therefore run after Astro's 404. Our plugin is listed in vite.plugins, so its post hook runs after Astro's, and stack.unshift(stack.pop()) moves our handler in front of everything. The trade-off: middlewares.stack belongs 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
Enter fullscreen mode Exit fullscreen mode

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 - The Astro.url.pathname will 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} />
Enter fullscreen mode Exit fullscreen mode

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`);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

There were two bugs:

  1. \/(?!.)" 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.
  2. The helper matches() returns match[1], but the pattern had no capture group. Even a matching pattern would have produced undefined.

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)',
);
Enter fullscreen mode Exit fullscreen mode

(?!\/) 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.

Top comments (0)