From 07a0bfef6e4d30c7579f3761e692193ee16c8173 Mon Sep 17 00:00:00 2001 From: David Montero Date: Mon, 15 Jun 2026 06:10:32 +0200 Subject: [PATCH] seo: emit trailing-slash canonical + sitemap URLs Static/docs/example routes are served as /index.html and nginx 301-redirects the slash-less form to add the trailing slash. The sitemap generator, the prerender canonical/og:url, and the client useSEO canonical all emitted the slash-LESS form, so every sitemap URL was fetched as a redirect (filed under 'Page with redirect' in Search Console) and each canonical pointed at a redirecting URL. Align all three to the trailing-slash form so sitemap URL == canonical == served URL == 200, with no redirect hop. - generate-sitemap.mjs: append '/' to every (root stays '/') - prerender-seo.mjs: withSlash() on canonical + og:url (routes + examples) - useSEO.ts: withTrailingSlash() on canonical + og:url (covers dynamic project pages too) --- frontend/scripts/generate-sitemap.mjs | 9 +++++++-- frontend/scripts/prerender-seo.mjs | 14 ++++++++++---- frontend/src/utils/useSEO.ts | 21 +++++++++++++++++++-- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/frontend/scripts/generate-sitemap.mjs b/frontend/scripts/generate-sitemap.mjs index c378f03b..b591f55d 100644 --- a/frontend/scripts/generate-sitemap.mjs +++ b/frontend/scripts/generate-sitemap.mjs @@ -64,6 +64,11 @@ const exampleUrls = exampleIds.map((id) => ({ priority: 0.6, })); +// Every carries a trailing slash. nginx serves the prerendered route +// files as `/index.html` and 301-redirects the slash-less form to add +// the slash, so a slash-less sitemap URL is fetched as a redirect and filed +// under "Page with redirect" in Search Console. Matching the served (slash) +// form keeps the sitemap URL == canonical == 200, with no redirect hop. const xml = ` @@ -71,7 +76,7 @@ ${indexable .map( (r) => ` - ${DOMAIN}${r.path} + ${DOMAIN}${r.path}${r.path.endsWith('/') ? '' : '/'} ${TODAY} ${r.changefreq ?? 'monthly'} ${r.priority ?? 0.5} @@ -82,7 +87,7 @@ ${exampleUrls .map( (u) => ` - ${u.loc} + ${u.loc}/ ${u.lastmod} ${u.changefreq} ${u.priority} diff --git a/frontend/scripts/prerender-seo.mjs b/frontend/scripts/prerender-seo.mjs index 3e383c1c..24416c3a 100644 --- a/frontend/scripts/prerender-seo.mjs +++ b/frontend/scripts/prerender-seo.mjs @@ -33,6 +33,12 @@ if (!existsSync(join(distDir, 'index.html'))) { const baseHtml = readFileSync(join(distDir, 'index.html'), 'utf-8'); const DOMAIN = 'https://velxio.dev'; +// nginx serves each prerendered route as `/index.html` and +// 301-redirects the slash-less URL to add the trailing slash. Canonical + +// og:url must therefore use the slash form (== the served URL == the sitemap +// entry), or Google sees a canonical that points to a redirecting URL. +const withSlash = (u) => (u.endsWith('/') ? u : `${u}/`); + // ── Mock browser globals for SSR ──────────────────────────────────────────── // Zustand's persist middleware and some components access these at import time. if (typeof globalThis.localStorage === 'undefined') { @@ -96,7 +102,7 @@ try { ); // Add/replace canonical URL - const canonicalTag = ``; + const canonicalTag = ``; if (html.includes(']*\/>/, canonicalTag); } else { @@ -114,7 +120,7 @@ try { ); html = html.replace( /`; + const canonicalTag = ``; if (html.includes(']*\/>/, canonicalTag); } else { @@ -178,7 +184,7 @@ try { html = html.replace(/