[{"data":1,"prerenderedAt":1659},["ShallowReactive",2],{"post-2026-05-28-nuxt-ssg-trailing-slash-hydration-trap-en":3,"surround-2026-05-28-nuxt-ssg-trailing-slash-hydration-trap-en":1650,"randomIndex-year-month-day-slug___en-{\"year\":\"2026\",\"month\":\"05\",\"day\":\"28\",\"slug\":\"nuxt-ssg-trailing-slash-hydration-trap\"}":706,"language-switch-year-month-day-slug___en-{\"year\":\"2026\",\"month\":\"05\",\"day\":\"28\",\"slug\":\"nuxt-ssg-trailing-slash-hydration-trap\"}-zh":1658},{"title":4,"date":5,"path":6,"tags":7,"body":12,"description":1649},"How Should You Handle Trailing Slashes in a Nuxt SSG Blog?","2026-05-28","/2026/05/28/nuxt-ssg-trailing-slash-hydration-trap",[8,9,10,11],"Nuxt","Vue.js","Front-End","SSG",{"type":13,"value":14,"toc":1637},"minimark",[15,24,43,48,51,86,92,96,99,150,161,165,175,229,247,250,378,382,388,422,567,574,578,596,604,617,620,720,723,727,736,743,750,1025,1028,1048,1055,1063,1071,1074,1137,1159,1175,1361,1383,1387,1390,1413,1416,1526,1530,1536,1545,1552,1559,1565,1571,1594,1614,1621,1630,1633],[16,17,18,19,23],"p",{},"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: ",[20,21,22],"strong",{},"every page URL should end with a trailing slash",".",[16,25,26,27,30,31,38,39,42],{},"It sounded like the kind of thing that should be solved with a single config line. In practice, Nuxt still has ",[20,28,29],{},"no unified official switch"," for trailing slash handling (",[32,33,37],"a",{"href":34,"rel":35},"https://github.com/nuxt/nuxt/issues/15462",[36],"nofollow","nuxt/nuxt#15462"," has been open since 2022), so the final solution in this project ended up being assembled from ",[20,40,41],{},"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.",[44,45,47],"h2",{"id":46},"why-use-trailing-slashes","Why Use Trailing Slashes?",[16,49,50],{},"The motivation is straightforward:",[52,53,54,70,80,83],"ul",{},[55,56,57,61,62,65,66,69],"li",{},[58,59,60],"code",{},"/2026/05/28/foo"," and ",[58,63,64],{},"/2026/05/28/foo/"," are, in theory, ",[20,67,68],{},"two different URLs"," to a search engine. You either publish one canonical form, or you allow both and live with the ambiguity.",[55,71,72,73,76,77,23],{},"In SSG output, the \"directory form\" feels more natural. ",[58,74,75],{},"about/index.html"," is cleaner and more extensible than ",[58,78,79],{},"about.html",[55,81,82],{},"Personally, I just prefer URLs that end with a slash.",[55,84,85],{},"My old Hexo-based blog used the same style, and I wanted to keep the existing URL shape unchanged after the rebuild.",[16,87,88,89,23],{},"Once the goal is \"every page has a trailing slash,\" the real task becomes making sure ",[20,90,91],{},"every place that emits a URL, receives a URL, or uses a URL as a key follows the same rule",[44,93,95],{"id":94},"layer-1-seo","Layer 1: SEO",[16,97,98],{},"The first thing that comes to mind is SEO, so the project enables:",[100,101,106],"pre",{"className":102,"code":103,"language":104,"meta":105,"style":105},"language-ts shiki shiki-themes one-light one-dark-pro","// nuxt.config.ts\nsite: {\n  trailingSlash: true,\n}\n","ts","",[58,107,108,117,128,144],{"__ignoreMap":105},[109,110,113],"span",{"class":111,"line":112},"line",1,[109,114,116],{"class":115},"sW2Sy","// nuxt.config.ts\n",[109,118,120,124],{"class":111,"line":119},2,[109,121,123],{"class":122},"sz0mV","site",[109,125,127],{"class":126},"s5ixo",": {\n",[109,129,131,134,137,141],{"class":111,"line":130},3,[109,132,133],{"class":122},"  trailingSlash",[109,135,136],{"class":126},": ",[109,138,140],{"class":139},"sAGMh","true",[109,142,143],{"class":126},",\n",[109,145,147],{"class":111,"line":146},4,[109,148,149],{"class":126},"}\n",[16,151,152,153,156,157,160],{},"This switch ",[20,154,155],{},"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 ",[58,158,159],{},"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.",[44,162,164],{"id":163},"layer-2-outbound-links","Layer 2: Outbound Links",[16,166,167,168,170,171,174],{},"The next layer is the ",[58,169,159],{}," generated by ",[58,172,173],{},"\u003CNuxtLink>",". Nuxt 4 provides an experimental setting for that:",[100,176,178],{"className":102,"code":177,"language":104,"meta":105,"style":105},"// nuxt.config.ts\nexperimental: {\n  defaults: {\n    nuxtLink: { trailingSlash: 'append' }\n  }\n}\n",[58,179,180,184,191,198,218,224],{"__ignoreMap":105},[109,181,182],{"class":111,"line":112},[109,183,116],{"class":115},[109,185,186,189],{"class":111,"line":119},[109,187,188],{"class":122},"experimental",[109,190,127],{"class":126},[109,192,193,196],{"class":111,"line":130},[109,194,195],{"class":122},"  defaults",[109,197,127],{"class":126},[109,199,200,203,206,209,211,215],{"class":111,"line":146},[109,201,202],{"class":122},"    nuxtLink",[109,204,205],{"class":126},": { ",[109,207,208],{"class":122},"trailingSlash",[109,210,136],{"class":126},[109,212,214],{"class":213},"sDhpE","'append'",[109,216,217],{"class":126}," }\n",[109,219,221],{"class":111,"line":220},5,[109,222,223],{"class":126},"  }\n",[109,225,227],{"class":111,"line":226},6,[109,228,149],{"class":126},[16,230,231,232,235,236,239,240,23],{},"Once enabled, ",[58,233,234],{},"\u003CNuxtLink to=\"/about\">"," renders to ",[58,237,238],{},"href=\"/about/\""," site-wide, ",[20,241,242,243,246],{},"whether or not the original ",[58,244,245],{},"to"," includes the slash",[16,248,249],{},"There are a few boundaries worth noting:",[52,251,252,270],{},[55,253,254,255,258,259,262,263,61,266,269],{},"This setting does ",[20,256,257],{},"not"," affect code-driven navigation like ",[58,260,261],{},"router.push('/about')",". If you push routes manually, you still need to normalize them yourself. In this project, most navigation already goes through ",[58,264,265],{},"localePath()",[58,267,268],{},"NuxtLinkLocale",", so that problem is largely avoided.",[55,271,272,273,276,277,280,281,284,285,288,289,291,292,371,374,375,23],{},"If ",[58,274,275],{},"localePath('/tags')"," returns ",[58,278,279],{},"/tags",", and you manually concatenate ",[58,282,283],{},"+ '/' + tagName",", the final segment still has no trailing slash. ",[20,286,287],{},"But"," NuxtLink's ",[58,290,214],{}," mode will add it back at render time. For example:",[100,293,297],{"className":294,"code":295,"language":296,"meta":105,"style":105},"language-vue shiki shiki-themes one-light one-dark-pro","\u003CNuxtLink :to=\"`${localePath('/tags')}/${encodeURIComponent(tag)}`\">\n","vue",[58,298,299],{"__ignoreMap":105},[109,300,301,304,308,311,313,316,319,322,326,330,333,337,339,341,344,347,350,352,355,357,360,362,364,366,368],{"class":111,"line":112},[109,302,303],{"class":126},"\u003C",[109,305,307],{"class":306},"sJa8x","NuxtLink",[109,309,310],{"class":126}," :",[109,312,245],{"class":139},[109,314,315],{"class":126},"=",[109,317,318],{"class":126},"\"",[109,320,321],{"class":213},"`",[109,323,325],{"class":324},"sAOjX","${",[109,327,329],{"class":328},"sAdtL","localePath",[109,331,332],{"class":126},"(",[109,334,336],{"class":335},"sWwuK","'",[109,338,279],{"class":213},[109,340,336],{"class":335},[109,342,343],{"class":126},")",[109,345,346],{"class":324},"}",[109,348,349],{"class":213},"/",[109,351,325],{"class":324},[109,353,354],{"class":328},"encodeURIComponent",[109,356,332],{"class":126},[109,358,359],{"class":122},"tag",[109,361,343],{"class":126},[109,363,346],{"class":324},[109,365,321],{"class":213},[109,367,318],{"class":126},[109,369,370],{"class":126},">\n",[372,373],"br",{},"This ultimately renders as ",[58,376,377],{},"/tags/Foo/",[44,379,381],{"id":380},"layer-3-hardcoded-links","Layer 3: Hardcoded Links",[16,383,384,385,387],{},"Even with ",[58,386,214],{}," in place, the project still writes hardcoded links in trailing-slash form as a second line of defense:",[100,389,391],{"className":294,"code":390,"language":296,"meta":105,"style":105},"\u003C!-- FooterContent.vue -->\n\u003CNuxtLinkLocale to=\"/donate/\" aria-label=\"Donate\">\n",[58,392,393,398],{"__ignoreMap":105},[109,394,395],{"class":111,"line":112},[109,396,397],{"class":115},"\u003C!-- FooterContent.vue -->\n",[109,399,400,402,404,407,409,412,415,417,420],{"class":111,"line":119},[109,401,303],{"class":126},[109,403,268],{"class":306},[109,405,406],{"class":139}," to",[109,408,315],{"class":126},[109,410,411],{"class":213},"\"/donate/\"",[109,413,414],{"class":139}," aria-label",[109,416,315],{"class":126},[109,418,419],{"class":213},"\"Donate\"",[109,421,370],{"class":126},[100,423,425],{"className":102,"code":424,"language":104,"meta":105,"style":105},"// Pagination.vue\nfunction getPageUrl(page: number) {\n  if (page \u003C 1 || page > props.totalPages) return '#'\n  return page === 1 ? `${props.urlPrefix}/` : `${props.urlPrefix}/page/${page}/`\n}\n",[58,426,427,432,458,502,563],{"__ignoreMap":105},[109,428,429],{"class":111,"line":112},[109,430,431],{"class":115},"// Pagination.vue\n",[109,433,434,438,441,443,447,451,455],{"class":111,"line":119},[109,435,437],{"class":436},"sLKXg","function",[109,439,440],{"class":328}," getPageUrl",[109,442,332],{"class":126},[109,444,446],{"class":445},"s8iYz","page",[109,448,450],{"class":449},"st7oF",":",[109,452,454],{"class":453},"sN9Y4"," number",[109,456,457],{"class":126},") {\n",[109,459,460,463,466,468,472,475,478,481,484,488,490,493,496,499],{"class":111,"line":130},[109,461,462],{"class":436},"  if",[109,464,465],{"class":126}," (",[109,467,446],{"class":122},[109,469,471],{"class":470},"s_Sar"," \u003C",[109,473,474],{"class":139}," 1",[109,476,477],{"class":470}," ||",[109,479,480],{"class":122}," page",[109,482,483],{"class":470}," >",[109,485,487],{"class":486},"s7GmK"," props",[109,489,23],{"class":126},[109,491,492],{"class":306},"totalPages",[109,494,495],{"class":126},") ",[109,497,498],{"class":436},"return",[109,500,501],{"class":213}," '#'\n",[109,503,504,507,509,512,514,518,521,523,526,529,532,534,537,539,541,543,545,547,549,551,554,556,558,560],{"class":111,"line":146},[109,505,506],{"class":436},"  return",[109,508,480],{"class":122},[109,510,511],{"class":470}," ===",[109,513,474],{"class":139},[109,515,517],{"class":516},"s7DPa"," ?",[109,519,520],{"class":213}," `",[109,522,325],{"class":324},[109,524,525],{"class":486},"props",[109,527,23],{"class":528},"sMj0N",[109,530,531],{"class":306},"urlPrefix",[109,533,346],{"class":324},[109,535,536],{"class":213},"/`",[109,538,310],{"class":516},[109,540,520],{"class":213},[109,542,325],{"class":324},[109,544,525],{"class":486},[109,546,23],{"class":528},[109,548,531],{"class":306},[109,550,346],{"class":324},[109,552,553],{"class":213},"/page/",[109,555,325],{"class":324},[109,557,446],{"class":122},[109,559,346],{"class":324},[109,561,562],{"class":213},"/`\n",[109,564,565],{"class":111,"line":220},[109,566,149],{"class":126},[16,568,569,570,573],{},"This habit has one practical benefit: if Nuxt ever renames or removes ",[58,571,572],{},"experimental.defaults.nuxtLink.trailingSlash",", the site does not suddenly fall apart overnight.",[44,575,577],{"id":576},"layer-4-prerender-output-layout","Layer 4: Prerender Output Layout",[16,579,580,581,584,585,588,589,592,593,23],{},"During SSG, Nitro is responsible for generating the final output. It has a ",[20,582,583],{},"default-enabled"," behavior called ",[58,586,587],{},"prerender.autoSubfolderIndex",", which writes each page to ",[58,590,591],{},"\u003Cpath>/index.html"," instead of ",[58,594,595],{},"\u003Cpath>.html",[100,597,602],{"className":598,"code":600,"language":601,"meta":105},[599],"language-text",".output/public/\n├── about/\n│   ├── index.html\n│   └── _payload.json\n├── 2026/\n│   └── 05/\n│       └── 28/\n│           └── nuxt-ssg-trailing-slash-hydration-trap/\n│               ├── index.html\n│               └── _payload.json\n└── ...\n","text",[58,603,600],{"__ignoreMap":105},[16,605,606,607,61,610,613,614,616],{},"That means whether the site is hosted on Vercel, or on something more traditional like Nginx or Caddy, requests for ",[58,608,609],{},"/about",[58,611,612],{},"/about/"," can both fall back to the same ",[58,615,75],{},". In other words, the problem of \"the user typed the URL without the slash\" is already mostly handled at the static file serving level.",[16,618,619],{},"As a side note, the project also explicitly lists several prerender routes:",[100,621,623],{"className":102,"code":622,"language":104,"meta":105,"style":105},"nitro: {\n  prerender: {\n    routes: [\n      '/rss.xml',\n      '/en/rss.xml',\n      '/search/sections.json',\n      '/tags/Vue.js',\n      ...Object.keys(blogConfig.redirects)\n    ]\n  }\n}\n",[58,624,625,632,639,647,654,661,668,676,704,710,715],{"__ignoreMap":105},[109,626,627,630],{"class":111,"line":112},[109,628,629],{"class":122},"nitro",[109,631,127],{"class":126},[109,633,634,637],{"class":111,"line":119},[109,635,636],{"class":122},"  prerender",[109,638,127],{"class":126},[109,640,641,644],{"class":111,"line":130},[109,642,643],{"class":122},"    routes",[109,645,646],{"class":126},": [\n",[109,648,649,652],{"class":111,"line":146},[109,650,651],{"class":213},"      '/rss.xml'",[109,653,143],{"class":126},[109,655,656,659],{"class":111,"line":220},[109,657,658],{"class":213},"      '/en/rss.xml'",[109,660,143],{"class":126},[109,662,663,666],{"class":111,"line":226},[109,664,665],{"class":213},"      '/search/sections.json'",[109,667,143],{"class":126},[109,669,671,674],{"class":111,"line":670},7,[109,672,673],{"class":213},"      '/tags/Vue.js'",[109,675,143],{"class":126},[109,677,679,682,685,687,690,692,695,697,701],{"class":111,"line":678},8,[109,680,681],{"class":449},"      ...",[109,683,684],{"class":486},"Object",[109,686,23],{"class":126},[109,688,689],{"class":328},"keys",[109,691,332],{"class":126},[109,693,694],{"class":486},"blogConfig",[109,696,23],{"class":126},[109,698,700],{"class":699},"sj4iG","redirects",[109,702,703],{"class":126},")\n",[109,705,707],{"class":111,"line":706},9,[109,708,709],{"class":126},"    ]\n",[109,711,713],{"class":111,"line":712},10,[109,714,223],{"class":126},[109,716,718],{"class":111,"line":717},11,[109,719,149],{"class":126},[16,721,722],{},"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.",[44,724,726],{"id":725},"layer-5-inbound-url-normalization-on-the-client","Layer 5: Inbound URL Normalization on the Client",[16,728,729,730,732,733,735],{},"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 ",[58,731,609],{},"? Should it be rewritten to ",[58,734,612],{},"?",[16,737,738,739,742],{},"The classic solution is an HTTP 301 redirect. But this site is deployed to ",[20,740,741],{},"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.",[16,744,745,746,749],{},"So this layer is handled ",[20,747,748],{},"entirely on the client"," with a global middleware:",[100,751,753],{"className":102,"code":752,"language":104,"meta":105,"style":105},"// app/middleware/trailing-slash.global.ts\nexport default defineNuxtRouteMiddleware((to) => {\n  if (import.meta.server) return\n  if (to.path === '/' || to.path.endsWith('/')) return\n\n  // Skip file-like URLs such as favicon.ico or rss.xml\n  const lastSegment = to.path.slice(to.path.lastIndexOf('/') + 1)\n  if (lastSegment.includes('.')) return\n\n  return navigateTo(\n    { path: to.path + '/', query: to.query, hash: to.hash },\n    { replace: true }\n  )\n})\n",[58,754,755,760,785,809,851,857,862,911,934,938,948,998,1013,1019],{"__ignoreMap":105},[109,756,757],{"class":111,"line":112},[109,758,759],{"class":115},"// app/middleware/trailing-slash.global.ts\n",[109,761,762,765,769,772,775,777,779,782],{"class":111,"line":119},[109,763,764],{"class":436},"export",[109,766,768],{"class":767},"sq3v1"," default",[109,770,771],{"class":328}," defineNuxtRouteMiddleware",[109,773,774],{"class":126},"((",[109,776,245],{"class":445},[109,778,495],{"class":126},[109,780,781],{"class":436},"=>",[109,783,784],{"class":126}," {\n",[109,786,787,789,791,794,796,799,801,804,806],{"class":111,"line":130},[109,788,462],{"class":436},[109,790,465],{"class":126},[109,792,793],{"class":436},"import",[109,795,23],{"class":126},[109,797,798],{"class":306},"meta",[109,800,23],{"class":126},[109,802,803],{"class":306},"server",[109,805,495],{"class":126},[109,807,808],{"class":436},"return\n",[109,810,811,813,815,817,819,822,824,827,829,831,833,836,838,841,843,846,849],{"class":111,"line":146},[109,812,462],{"class":436},[109,814,465],{"class":126},[109,816,245],{"class":486},[109,818,23],{"class":126},[109,820,821],{"class":306},"path",[109,823,511],{"class":470},[109,825,826],{"class":213}," '/'",[109,828,477],{"class":470},[109,830,406],{"class":486},[109,832,23],{"class":126},[109,834,821],{"class":835},"s2QsP",[109,837,23],{"class":126},[109,839,840],{"class":328},"endsWith",[109,842,332],{"class":126},[109,844,845],{"class":213},"'/'",[109,847,848],{"class":126},")) ",[109,850,808],{"class":436},[109,852,853],{"class":111,"line":220},[109,854,856],{"emptyLinePlaceholder":855},true,"\n",[109,858,859],{"class":111,"line":226},[109,860,861],{"class":115},"  // Skip file-like URLs such as favicon.ico or rss.xml\n",[109,863,864,867,871,874,876,878,880,882,885,887,889,891,893,895,898,900,902,904,907,909],{"class":111,"line":670},[109,865,866],{"class":436},"  const",[109,868,870],{"class":869},"sNmU0"," lastSegment",[109,872,873],{"class":470}," =",[109,875,406],{"class":486},[109,877,23],{"class":126},[109,879,821],{"class":835},[109,881,23],{"class":126},[109,883,884],{"class":328},"slice",[109,886,332],{"class":126},[109,888,245],{"class":486},[109,890,23],{"class":126},[109,892,821],{"class":835},[109,894,23],{"class":126},[109,896,897],{"class":328},"lastIndexOf",[109,899,332],{"class":126},[109,901,845],{"class":213},[109,903,495],{"class":126},[109,905,906],{"class":470},"+",[109,908,474],{"class":139},[109,910,703],{"class":126},[109,912,913,915,917,920,922,925,927,930,932],{"class":111,"line":678},[109,914,462],{"class":436},[109,916,465],{"class":126},[109,918,919],{"class":486},"lastSegment",[109,921,23],{"class":126},[109,923,924],{"class":328},"includes",[109,926,332],{"class":126},[109,928,929],{"class":213},"'.'",[109,931,848],{"class":126},[109,933,808],{"class":436},[109,935,936],{"class":111,"line":706},[109,937,856],{"emptyLinePlaceholder":855},[109,939,940,942,945],{"class":111,"line":712},[109,941,506],{"class":436},[109,943,944],{"class":328}," navigateTo",[109,946,947],{"class":126},"(\n",[109,949,950,953,955,957,959,961,963,966,968,971,974,976,978,980,982,984,987,989,991,993,995],{"class":111,"line":717},[109,951,952],{"class":126},"    { ",[109,954,821],{"class":306},[109,956,450],{"class":449},[109,958,406],{"class":486},[109,960,23],{"class":126},[109,962,821],{"class":306},[109,964,965],{"class":470}," +",[109,967,826],{"class":213},[109,969,970],{"class":126},", ",[109,972,973],{"class":306},"query",[109,975,450],{"class":449},[109,977,406],{"class":486},[109,979,23],{"class":126},[109,981,973],{"class":306},[109,983,970],{"class":126},[109,985,986],{"class":306},"hash",[109,988,450],{"class":449},[109,990,406],{"class":486},[109,992,23],{"class":126},[109,994,986],{"class":306},[109,996,997],{"class":126}," },\n",[109,999,1001,1003,1006,1008,1011],{"class":111,"line":1000},12,[109,1002,952],{"class":126},[109,1004,1005],{"class":306},"replace",[109,1007,450],{"class":449},[109,1009,1010],{"class":139}," true",[109,1012,217],{"class":126},[109,1014,1016],{"class":111,"line":1015},13,[109,1017,1018],{"class":126},"  )\n",[109,1020,1022],{"class":111,"line":1021},14,[109,1023,1024],{"class":126},"})\n",[16,1026,1027],{},"There are a few important details here:",[52,1029,1030,1036,1042],{},[55,1031,1032,1035],{},[58,1033,1034],{},"import.meta.server"," returns 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.",[55,1037,1038,1041],{},[58,1039,1040],{},"replace: true"," ensures the browser replaces the current history entry instead of keeping a useless \"slashless version\" in the back stack.",[55,1043,1044,1045,1047],{},"Paths containing ",[58,1046,23],{}," are treated as file-like URLs and skipped, so static assets are not accidentally rewritten.",[16,1049,1050,1051,1054],{},"With this layer in place, the whole setup stays at ",[20,1052,1053],{},"zero HTTP 301 redirects",", while remaining independent of any specific hosting platform.",[44,1056,1058,1059,1062],{"id":1057},"layer-6-useasyncdata-keys-the-hidden-minefield","Layer 6: ",[58,1060,1061],{},"useAsyncData"," Keys, the Hidden Minefield",[16,1064,1065,1066,23],{},"Even after the first five layers are aligned, one subtle problem remains: ",[20,1067,1068,1069],{},"the key used by ",[58,1070,1061],{},[16,1072,1073],{},"It is very easy to write something like this:",[100,1075,1077],{"className":102,"code":1076,"language":104,"meta":105,"style":105},"const { data } = useAsyncData(\n  `randomIndex${route.path}`,\n  async () => ...\n)\n",[58,1078,1079,1100,1120,1133],{"__ignoreMap":105},[109,1080,1081,1084,1087,1090,1093,1095,1098],{"class":111,"line":112},[109,1082,1083],{"class":436},"const",[109,1085,1086],{"class":126}," { ",[109,1088,1089],{"class":869},"data",[109,1091,1092],{"class":126}," } ",[109,1094,315],{"class":470},[109,1096,1097],{"class":328}," useAsyncData",[109,1099,947],{"class":126},[109,1101,1102,1105,1107,1110,1112,1114,1116,1118],{"class":111,"line":119},[109,1103,1104],{"class":213},"  `randomIndex",[109,1106,325],{"class":324},[109,1108,1109],{"class":486},"route",[109,1111,23],{"class":528},[109,1113,821],{"class":306},[109,1115,346],{"class":324},[109,1117,321],{"class":213},[109,1119,143],{"class":126},[109,1121,1122,1125,1128,1130],{"class":111,"line":130},[109,1123,1124],{"class":436},"  async",[109,1126,1127],{"class":126}," () ",[109,1129,781],{"class":436},[109,1131,1132],{"class":449}," ...\n",[109,1134,1135],{"class":111,"line":146},[109,1136,703],{"class":126},[16,1138,1139,1140,1143,1144,1147,1148,1151,1152,1155,1156,1158],{},"The issue is that ",[58,1141,1142],{},"route.path"," is ",[20,1145,1146],{},"not guaranteed to be identical"," across SSR, client hydration, prerender, and later SPA navigations. If SSR computes the key as ",[58,1149,1150],{},"randomIndex/about"," while the client computes ",[58,1153,1154],{},"randomIndex/about/",", the payload lookup misses, ",[58,1157,1061],{}," reruns on the client, and that component silently degrades into CSR.",[16,1160,1161,1162,1170,1171,1174],{},"The fix used in this project is simple: ",[20,1163,1164,1165,1167,1168],{},"never let ",[58,1166,1061],{}," keys depend on ",[58,1169,1142],{},". If the key needs route information, use ",[58,1172,1173],{},"route.name + route.params"," instead:",[100,1176,1178],{"className":102,"code":1177,"language":104,"meta":105,"style":105},"const route = useRoute()\nconst routeKey = `${String(route.name ?? 'unknown')}-${JSON.stringify(route.params)}`\n\nconst { data: randomIndex } = useAsyncData(\n  `randomIndex-${routeKey}`,\n  async () => Math.floor(Math.random() * appConfig.appearance.backgrounds.length)\n)\n",[58,1179,1180,1195,1264,1268,1289,1305,1357],{"__ignoreMap":105},[109,1181,1182,1184,1187,1189,1192],{"class":111,"line":112},[109,1183,1083],{"class":436},[109,1185,1186],{"class":869}," route",[109,1188,873],{"class":470},[109,1190,1191],{"class":328}," useRoute",[109,1193,1194],{"class":126},"()\n",[109,1196,1197,1199,1202,1204,1206,1208,1211,1213,1215,1217,1220,1223,1226,1229,1231,1233,1235,1238,1240,1243,1245,1248,1250,1252,1254,1257,1259,1261],{"class":111,"line":119},[109,1198,1083],{"class":436},[109,1200,1201],{"class":869}," routeKey",[109,1203,873],{"class":470},[109,1205,520],{"class":213},[109,1207,325],{"class":324},[109,1209,1210],{"class":328},"String",[109,1212,332],{"class":126},[109,1214,1109],{"class":486},[109,1216,23],{"class":528},[109,1218,1219],{"class":306},"name",[109,1221,1222],{"class":470}," ??",[109,1224,1225],{"class":335}," '",[109,1227,1228],{"class":213},"unknown",[109,1230,336],{"class":335},[109,1232,343],{"class":126},[109,1234,346],{"class":324},[109,1236,1237],{"class":213},"-",[109,1239,325],{"class":324},[109,1241,1242],{"class":869},"JSON",[109,1244,23],{"class":528},[109,1246,1247],{"class":328},"stringify",[109,1249,332],{"class":126},[109,1251,1109],{"class":486},[109,1253,23],{"class":528},[109,1255,1256],{"class":306},"params",[109,1258,343],{"class":126},[109,1260,346],{"class":324},[109,1262,1263],{"class":213},"`\n",[109,1265,1266],{"class":111,"line":130},[109,1267,856],{"emptyLinePlaceholder":855},[109,1269,1270,1272,1274,1276,1278,1281,1283,1285,1287],{"class":111,"line":146},[109,1271,1083],{"class":436},[109,1273,1086],{"class":126},[109,1275,1089],{"class":306},[109,1277,136],{"class":126},[109,1279,1280],{"class":869},"randomIndex",[109,1282,1092],{"class":126},[109,1284,315],{"class":470},[109,1286,1097],{"class":328},[109,1288,947],{"class":126},[109,1290,1291,1294,1296,1299,1301,1303],{"class":111,"line":220},[109,1292,1293],{"class":213},"  `randomIndex-",[109,1295,325],{"class":324},[109,1297,1298],{"class":122},"routeKey",[109,1300,346],{"class":324},[109,1302,321],{"class":213},[109,1304,143],{"class":126},[109,1306,1307,1309,1311,1313,1316,1318,1321,1323,1326,1328,1331,1334,1337,1340,1342,1345,1347,1350,1352,1355],{"class":111,"line":226},[109,1308,1124],{"class":436},[109,1310,1127],{"class":126},[109,1312,781],{"class":436},[109,1314,1315],{"class":486}," Math",[109,1317,23],{"class":126},[109,1319,1320],{"class":328},"floor",[109,1322,332],{"class":126},[109,1324,1325],{"class":486},"Math",[109,1327,23],{"class":126},[109,1329,1330],{"class":328},"random",[109,1332,1333],{"class":126},"() ",[109,1335,1336],{"class":470},"*",[109,1338,1339],{"class":486}," appConfig",[109,1341,23],{"class":126},[109,1343,1344],{"class":835},"appearance",[109,1346,23],{"class":126},[109,1348,1349],{"class":835},"backgrounds",[109,1351,23],{"class":126},[109,1353,1354],{"class":306},"length",[109,1356,703],{"class":126},[109,1358,1359],{"class":111,"line":670},[109,1360,703],{"class":126},[16,1362,1363,1366,1367,1370,1371,1374,1375,1378,1379,1382],{},[58,1364,1365],{},"route.name"," is the internal vue-router route name, and in this project i18n generates values like ",[58,1368,1369],{},"about___zh",". ",[58,1372,1373],{},"route.params"," contains only the dynamic segments. Both remain stable across contexts. The final ",[58,1376,1377],{},"_payload.json"," key looks like ",[58,1380,1381],{},"randomIndex-about___zh-{}",", completely detached from trailing slash shape.",[44,1384,1386],{"id":1385},"recap","Recap",[16,1388,1389],{},"The trailing slash strategy of this site can be summarized in one sentence:",[1391,1392,1393],"blockquote",{},[16,1394,1395,1396,1399,1400,1403,1404,1407,1408,1410,1411,23],{},"Let Nitro generate ",[58,1397,1398],{},"xxx/index.html",", let ",[58,1401,1402],{},"site.trailingSlash"," publish the canonical URL form for SEO, let ",[58,1405,1406],{},"nuxtLink.trailingSlash: 'append'"," normalize outbound links, let hardcoded links follow the same convention, let a global client middleware normalize inbound URLs, and keep ",[58,1409,1061],{}," keys independent from ",[58,1412,1142],{},[16,1414,1415],{},"Here is the same setup as a quick table:",[1417,1418,1419,1435],"table",{},[1420,1421,1422],"thead",{},[1423,1424,1425,1429,1432],"tr",{},[1426,1427,1428],"th",{},"Layer",[1426,1430,1431],{},"Config / Code",[1426,1433,1434],{},"What It Solves",[1436,1437,1438,1452,1467,1481,1499,1510],"tbody",{},[1423,1439,1440,1444,1449],{},[1441,1442,1443],"td",{},"SEO",[1441,1445,1446],{},[58,1447,1448],{},"site.trailingSlash: true",[1441,1450,1451],{},"Canonical, sitemap, OG URLs",[1423,1453,1454,1457,1461],{},[1441,1455,1456],{},"Outbound links",[1441,1458,1459],{},[58,1460,1406],{},[1441,1462,1463,170,1465],{},[58,1464,159],{},[58,1466,173],{},[1423,1468,1469,1472,1478],{},[1441,1470,1471],{},"Hardcoded links",[1441,1473,1474,1477],{},[58,1475,1476],{},"to=\"/donate/\""," and similar",[1441,1479,1480],{},"A second safety net and stylistic consistency",[1423,1482,1483,1486,1491],{},[1441,1484,1485],{},"Output layout",[1441,1487,1488],{},[58,1489,1490],{},"nitro.prerender.autoSubfolderIndex",[1441,1492,1493,1494,61,1496,1498],{},"Makes ",[58,1495,609],{},[58,1497,612],{}," hit the same file",[1423,1500,1501,1504,1507],{},[1441,1502,1503],{},"Inbound URLs",[1441,1505,1506],{},"Global client middleware",[1441,1508,1509],{},"Fixes slashless URLs typed by users or linked externally",[1423,1511,1512,1515,1523],{},[1441,1513,1514],{},"Data layer",[1441,1516,1517,1519,1520],{},[58,1518,1061],{}," keys based on ",[58,1521,1522],{},"route.name + params",[1441,1524,1525],{},"Prevents payload misses and hydration mismatch",[44,1527,1529],{"id":1528},"a-small-side-story-how-this-setup-was-finalized","A Small Side Story: How This Setup Was Finalized",[16,1531,1532,1533,1535],{},"To be clear, I did not work out this entire setup perfectly on day one. The last two layers, the client middleware and the ",[58,1534,1061],{}," key rewrite, were added only because I had to debug a very odd issue recently.",[16,1537,1538,1539,1541,1542],{},"One day I opened my own ",[58,1540,612],{}," page and noticed something strange: the background image changed every time the page loaded. After opening DevTools, I found Vue reporting ",[58,1543,1544],{},"Hydration completed but contains mismatches.",[16,1546,1547],{},[1548,1549],"img",{"alt":1550,"src":1551},"Console Warning","https://static.031130.xyz/uploads/2026/05/28/e1dab1bdd0721.webp",[16,1553,1554,1555,1558],{},"That made no sense at first. This was a pure SSG page. The HTML under ",[58,1556,1557],{},"\u003Cdiv id=\"__nuxt\">"," looked perfectly fine. Why would the client fail to hydrate it?",[16,1560,1561,1562,1564],{},"The clue was in how ",[58,1563,1061],{}," 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.",[16,1566,1567,1568,1570],{},"Looking into the generated ",[58,1569,1377],{},", I found this:",[100,1572,1576],{"className":1573,"code":1574,"language":1575,"meta":105,"style":105},"language-json shiki shiki-themes one-light one-dark-pro","{\"randomIndex/about\": ...}\n","json",[58,1577,1578],{"__ignoreMap":105},[109,1579,1580,1583,1586,1588,1592],{"class":111,"line":112},[109,1581,1582],{"class":126},"{",[109,1584,1585],{"class":306},"\"randomIndex/about\"",[109,1587,136],{"class":126},[109,1589,1591],{"class":1590},"sUNH4","...",[109,1593,149],{"class":126},[16,1595,1596,1597,1600,1601,1604,1605,1607,1608,1610,1611,1613],{},"The key had ",[20,1598,1599],{},"no trailing slash",". But the file itself lived at ",[58,1602,1603],{},".output/public/about/_payload.json",", and the browser was visiting ",[58,1606,612],{},". That meant the client was building ",[58,1609,1154],{}," from ",[58,1612,1142],{},", with one extra slash compared to the SSR side.",[16,1615,1616,1617,1620],{},"Then everything made sense. ",[58,1618,1619],{},"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.",[16,1622,1623,1624,1626,1627,1629],{},"The root cause was that ",[58,1625,1142],{}," was not actually identical between SSR and the client because of when Nitro normalizes the prerendered path. Once I replaced the key with ",[58,1628,1522],{},", the issue disappeared.",[16,1631,1632],{},"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.",[1634,1635,1636],"style",{},"html pre.shiki code .sW2Sy, html code.shiki .sW2Sy{--shiki-default:#A0A1A7;--shiki-default-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic}html pre.shiki code .sz0mV, html code.shiki .sz0mV{--shiki-default:#383A42;--shiki-dark:#E06C75}html pre.shiki code .s5ixo, html code.shiki .s5ixo{--shiki-default:#383A42;--shiki-dark:#ABB2BF}html pre.shiki code .sAGMh, html code.shiki .sAGMh{--shiki-default:#986801;--shiki-dark:#D19A66}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sDhpE, html code.shiki .sDhpE{--shiki-default:#50A14F;--shiki-dark:#98C379}html pre.shiki code .sJa8x, html code.shiki .sJa8x{--shiki-default:#E45649;--shiki-dark:#E06C75}html pre.shiki code .sAOjX, html code.shiki .sAOjX{--shiki-default:#CA1243;--shiki-dark:#C678DD}html pre.shiki code .sAdtL, html code.shiki .sAdtL{--shiki-default:#4078F2;--shiki-dark:#61AFEF}html pre.shiki code .sWwuK, html code.shiki .sWwuK{--shiki-default:#CA1243;--shiki-dark:#98C379}html pre.shiki code .sLKXg, html code.shiki .sLKXg{--shiki-default:#A626A4;--shiki-dark:#C678DD}html pre.shiki code .s8iYz, html code.shiki .s8iYz{--shiki-default:#383A42;--shiki-default-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic}html pre.shiki code .st7oF, html code.shiki .st7oF{--shiki-default:#0184BC;--shiki-dark:#ABB2BF}html pre.shiki code .sN9Y4, html code.shiki .sN9Y4{--shiki-default:#0184BC;--shiki-dark:#E5C07B}html pre.shiki code .s_Sar, html code.shiki .s_Sar{--shiki-default:#0184BC;--shiki-dark:#56B6C2}html pre.shiki code .s7GmK, html code.shiki .s7GmK{--shiki-default:#383A42;--shiki-dark:#E5C07B}html pre.shiki code .s7DPa, html code.shiki .s7DPa{--shiki-default:#0184BC;--shiki-dark:#C678DD}html pre.shiki code .sMj0N, html code.shiki .sMj0N{--shiki-default:#50A14F;--shiki-dark:#ABB2BF}html pre.shiki code .sj4iG, html code.shiki .sj4iG{--shiki-default:#C18401;--shiki-dark:#E06C75}html pre.shiki code .sq3v1, html code.shiki .sq3v1{--shiki-default:#E45649;--shiki-dark:#C678DD}html pre.shiki code .s2QsP, html code.shiki .s2QsP{--shiki-default:#E45649;--shiki-dark:#E5C07B}html pre.shiki code .sNmU0, html code.shiki .sNmU0{--shiki-default:#986801;--shiki-dark:#E5C07B}html pre.shiki code .sUNH4, html code.shiki .sUNH4{--shiki-default:white;--shiki-dark:#FFFFFF}",{"title":105,"searchDepth":119,"depth":119,"links":1638},[1639,1640,1641,1642,1643,1644,1645,1647,1648],{"id":46,"depth":119,"text":47},{"id":94,"depth":119,"text":95},{"id":163,"depth":119,"text":164},{"id":380,"depth":119,"text":381},{"id":576,"depth":119,"text":577},{"id":725,"depth":119,"text":726},{"id":1057,"depth":119,"text":1646},"Layer 6: useAsyncData Keys, the Hidden Minefield",{"id":1385,"depth":119,"text":1386},{"id":1528,"depth":119,"text":1529},"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.",[1651,1652],null,{"title":1653,"path":1654,"stem":1655,"date":1656,"lang":1657,"children":-1},"Xiaomi Book Pro 14 (Ultra X7) Linux Compatibility Testing","/2026/04/30/xiaomi-book-pro-14-2026-on-linux","posts/en/xiaomi-book-pro-14-2026-on-linux","2026-04-30","en","/2026/05/28/nuxt-ssg-trailing-slash-hydration-trap/",1779982442952]