This blog is a pure SSG site built with Nuxt v4, Nuxt Content v3, and i18n. When I first set it up, I casually made what looked like an unimportant decision: every page URL should end with a trailing slash.
It sounded like the kind of thing that should be solved with a single config line. In practice, Nuxt still has no unified official switch for trailing slash handling (nuxt/nuxt#15462 has been open since 2022), so the final solution in this project ended up being assembled from six different layers. This post is a full walkthrough of every trailing-slash-related setting in the site, mainly as documentation for my future self.
Why Use Trailing Slashes?
The motivation is straightforward:
/2026/05/28/fooand/2026/05/28/foo/are, in theory, two different URLs to a search engine. You either publish one canonical form, or you allow both and live with the ambiguity.- In SSG output, the "directory form" feels more natural.
about/index.htmlis cleaner and more extensible thanabout.html. - Personally, I just prefer URLs that end with a slash.
- My old Hexo-based blog used the same style, and I wanted to keep the existing URL shape unchanged after the rebuild.
Once the goal is "every page has a trailing slash," the real task becomes making sure every place that emits a URL, receives a URL, or uses a URL as a key follows the same rule.
Layer 1: SEO
The first thing that comes to mind is SEO, so the project enables:
// nuxt.config.ts
site: {
trailingSlash: true,
}
This switch only affects URLs generated by the SEO stack: canonical links, sitemap.xml, robots.txt, OpenGraph URLs, and similar metadata. It does not rewrite the actual href values rendered on the page, and it does not intercept inbound requests. Still, since this layer is responsible for the "publicly declared" URL shape of the site, it should be enabled.
Layer 2: Outbound Links
The next layer is the href generated by <NuxtLink>. Nuxt 4 provides an experimental setting for that:
// nuxt.config.ts
experimental: {
defaults: {
nuxtLink: { trailingSlash: 'append' }
}
}
Once enabled, <NuxtLink to="/about"> renders to href="/about/" site-wide, whether or not the original to includes the slash.
There are a few boundaries worth noting:
- This setting does not affect code-driven navigation like
router.push('/about'). If you push routes manually, you still need to normalize them yourself. In this project, most navigation already goes throughlocalePath()andNuxtLinkLocale, so that problem is largely avoided. - If
localePath('/tags')returns/tags, and you manually concatenate+ '/' + tagName, the final segment still has no trailing slash. But NuxtLink's'append'mode will add it back at render time. For example:<NuxtLink :to="`${localePath('/tags')}/${encodeURIComponent(tag)}`">
This ultimately renders as/tags/Foo/.
Layer 3: Hardcoded Links
Even with 'append' in place, the project still writes hardcoded links in trailing-slash form as a second line of defense:
<!-- FooterContent.vue -->
<NuxtLinkLocale to="/donate/" aria-label="Donate">
// Pagination.vue
function getPageUrl(page: number) {
if (page < 1 || page > props.totalPages) return '#'
return page === 1 ? `${props.urlPrefix}/` : `${props.urlPrefix}/page/${page}/`
}
This habit has one practical benefit: if Nuxt ever renames or removes experimental.defaults.nuxtLink.trailingSlash, the site does not suddenly fall apart overnight.
Layer 4: Prerender Output Layout
During SSG, Nitro is responsible for generating the final output. It has a default-enabled behavior called prerender.autoSubfolderIndex, which writes each page to <path>/index.html instead of <path>.html.
.output/public/
├── about/
│ ├── index.html
│ └── _payload.json
├── 2026/
│ └── 05/
│ └── 28/
│ └── nuxt-ssg-trailing-slash-hydration-trap/
│ ├── index.html
│ └── _payload.json
└── ...
That means whether the site is hosted on Vercel, or on something more traditional like Nginx or Caddy, requests for /about and /about/ can both fall back to the same about/index.html. In other words, the problem of "the user typed the URL without the slash" is already mostly handled at the static file serving level.
As a side note, the project also explicitly lists several prerender routes:
nitro: {
prerender: {
routes: [
'/rss.xml',
'/en/rss.xml',
'/search/sections.json',
'/tags/Vue.js',
...Object.keys(blogConfig.redirects)
]
}
}
These are routes the crawler cannot discover automatically, so they must be fed into prerender explicitly. They are not directly about trailing slashes, but they belong to the same Nitro layer and are worth mentioning for completeness.
Layer 5: Inbound URL Normalization on the Client
At this point, SEO, outbound links, and output layout are all aligned. But one class of scenarios is still missing: what if a user manually types a slashless URL, or arrives from an external link, and the browser address bar is showing /about? Should it be rewritten to /about/?
The classic solution is an HTTP 301 redirect. But this site is deployed to two different platforms, Vercel and Caddy, so doing it with 301s would mean duplicating rules on both sides. I wanted to avoid that. And since the site is fully static, I preferred not to depend on platform-specific redirect behavior at all.
So this layer is handled entirely on the client with a global middleware:
// app/middleware/trailing-slash.global.ts
export default defineNuxtRouteMiddleware((to) => {
if (import.meta.server) return
if (to.path === '/' || to.path.endsWith('/')) return
// Skip file-like URLs such as favicon.ico or rss.xml
const lastSegment = to.path.slice(to.path.lastIndexOf('/') + 1)
if (lastSegment.includes('.')) return
return navigateTo(
{ path: to.path + '/', query: to.query, hash: to.hash },
{ replace: true }
)
})
There are a few important details here:
import.meta.serverreturns immediately. During SSG, Nitro has already normalized the route shape. If the middleware also redirects on the server side, it may produce unwanted 30x responses in the generated output.replace: trueensures the browser replaces the current history entry instead of keeping a useless "slashless version" in the back stack.- Paths containing
.are treated as file-like URLs and skipped, so static assets are not accidentally rewritten.
With this layer in place, the whole setup stays at zero HTTP 301 redirects, while remaining independent of any specific hosting platform.
Layer 6: useAsyncData Keys, the Hidden Minefield
Even after the first five layers are aligned, one subtle problem remains: the key used by useAsyncData.
It is very easy to write something like this:
const { data } = useAsyncData(
`randomIndex${route.path}`,
async () => ...
)
The issue is that route.path is not guaranteed to be identical across SSR, client hydration, prerender, and later SPA navigations. If SSR computes the key as randomIndex/about while the client computes randomIndex/about/, the payload lookup misses, useAsyncData reruns on the client, and that component silently degrades into CSR.
The fix used in this project is simple: never let useAsyncData keys depend on route.path. If the key needs route information, use route.name + route.params instead:
const route = useRoute()
const routeKey = `${String(route.name ?? 'unknown')}-${JSON.stringify(route.params)}`
const { data: randomIndex } = useAsyncData(
`randomIndex-${routeKey}`,
async () => Math.floor(Math.random() * appConfig.appearance.backgrounds.length)
)
route.name is the internal vue-router route name, and in this project i18n generates values like about___zh. route.params contains only the dynamic segments. Both remain stable across contexts. The final _payload.json key looks like randomIndex-about___zh-{}, completely detached from trailing slash shape.
Recap
The trailing slash strategy of this site can be summarized in one sentence:
Let Nitro generate
xxx/index.html, letsite.trailingSlashpublish the canonical URL form for SEO, letnuxtLink.trailingSlash: 'append'normalize outbound links, let hardcoded links follow the same convention, let a global client middleware normalize inbound URLs, and keepuseAsyncDatakeys independent fromroute.path.
Here is the same setup as a quick table:
| Layer | Config / Code | What It Solves |
|---|---|---|
| SEO | site.trailingSlash: true | Canonical, sitemap, OG URLs |
| Outbound links | nuxtLink.trailingSlash: 'append' | href generated by <NuxtLink> |
| Hardcoded links | to="/donate/" and similar | A second safety net and stylistic consistency |
| Output layout | nitro.prerender.autoSubfolderIndex | Makes /about and /about/ hit the same file |
| Inbound URLs | Global client middleware | Fixes slashless URLs typed by users or linked externally |
| Data layer | useAsyncData keys based on route.name + params | Prevents payload misses and hydration mismatch |
A Small Side Story: How This Setup Was Finalized
To be clear, I did not work out this entire setup perfectly on day one. The last two layers, the client middleware and the useAsyncData key rewrite, were added only because I had to debug a very odd issue recently.
One day I opened my own /about/ page and noticed something strange: the background image changed every time the page loaded. After opening DevTools, I found Vue reporting Hydration completed but contains mismatches.
Console Warning
That made no sense at first. This was a pure SSG page. The HTML under <div id="__nuxt"> looked perfectly fine. Why would the client fail to hydrate it?
The clue was in how useAsyncData reads from the payload: if the key matches, the client reuses the payload; if the key does not match, it reruns the data function. So the only reasonable explanation was a key mismatch.
Looking into the generated _payload.json, I found this:
{"randomIndex/about": ...}
The key had no trailing slash. But the file itself lived at .output/public/about/_payload.json, and the browser was visiting /about/. That meant the client was building randomIndex/about/ from route.path, with one extra slash compared to the SSR side.
Then everything made sense. Math.random() reran on the client, the DOM diverged from the server-rendered version, hydration broke, and the page fell back to a re-render.
The root cause was that route.path was not actually identical between SSR and the client because of when Nitro normalizes the prerendered path. Once I replaced the key with route.name + params, the issue disappeared.
After that, I realized the slashless URL in the address bar was still worth cleaning up, so I added the client middleware as well. That was the point where the whole trailing slash strategy finally became complete.