seo: own-locale canonical + hreflang for localized pages; /editor prerendered

- useSEO: for the static SEO routes (marketing pages, examples gallery,
  editor, home) the canonical is now the page's OWN locale variant and the
  head lists every locale variant as rel=alternate hreflang plus x-default
  on the locale-less English URL. /es/ and /zh-cn/ were declaring
  canonical=/ while Google indexed and ranked them on their own — an
  unstable contradiction. UGC pages (projects, profiles, example detail
  pages) are the same English content under every prefix and keep a single
  locale-less canonical with no hreflang.
- prerender-seo.mjs emits the same hreflang set into each prerendered page
  and writes dist/seo-routes.json (path/title/description) so the prod app
  shell can serve the /<locale>/<route> variants (which have no static file)
  with the route's own head.
- /editor gets seoMeta and a static SSR summary component, so it is
  prerendered: nginx now serves /editor/ as a real page and 301s /editor to
  it. Both forms answered 200 with the homepage head before, and Google
  indexed /editor and /editor/ as two pages.
This commit is contained in:
David Montero 2026-08-18 16:50:39 +02:00
parent 1b735c9c30
commit 4da00840c6
4 changed files with 139 additions and 2 deletions

View File

@ -86,6 +86,36 @@ try {
const routes = getPrerenderedRoutes();
const exampleRoutes = getPrerenderedExampleRoutes();
// hreflang for the localized (marketing/static) pages: every locale
// variant of the route plus x-default on the locale-less English URL.
// The SPA serves the same page translated under /<locale>/..., so each
// variant is a real page (see useSEO.ts, which emits the same set on the
// client, and the prod app shell, which emits it for the /<locale>/
// variants nginx has no static file for).
const { LOCALES, LOCALE_META, DEFAULT_LOCALE } = await vite.ssrLoadModule('/src/i18n/config.ts');
const localizedPath = (path, locale) =>
locale === DEFAULT_LOCALE ? path : `/${locale}${path === '/' ? '' : path}`;
const hreflangTags = (routePath) => {
const variant = (l) => withSlash(`${DOMAIN}${localizedPath(routePath, l)}`);
return [
...LOCALES.map((l) => `<link rel="alternate" hreflang="${LOCALE_META[l].htmlLang}" href="${variant(l)}" />`),
`<link rel="alternate" hreflang="x-default" href="${variant(DEFAULT_LOCALE)}" />`,
].join('\n ');
};
const withHreflang = (html, routePath) =>
html.replace('</head>', ` ${hreflangTags(routePath)}\n </head>`);
// Route metadata for the prod app shell (title/description per SEO route),
// so /<locale>/<route> paths that fall through to it get the route's own
// head instead of the homepage's.
writeFileSync(
join(distDir, 'seo-routes.json'),
JSON.stringify(
routes.map((r) => ({ path: r.path, title: r.seoMeta.title, description: r.seoMeta.description })),
),
'utf-8',
);
console.log(`📄 Prerendering ${routes.length} SEO pages + ${exampleRoutes.length} example pages...\n`);
for (const route of routes) {
@ -114,6 +144,7 @@ try {
} else {
html = html.replace('</head>', ` ${canonicalTag}\n </head>`);
}
html = withHreflang(html, route.path);
// Replace OG tags
html = html.replace(

View File

@ -19,7 +19,27 @@ import { ExampleDetailPage } from './pages/ExampleDetailPage';
// Map route paths to their React component. The OSS build prerenders only
// what it ships (the examples gallery); the marketing surface lives in the
// pro overlay and is merged in by loadRouteComponents() below.
/**
* /editor is the live workspace (Monaco, WASM engines) — nothing there
* survives renderToString, so its prerender is a static crawlable summary.
* Being prerendered at all is what matters: nginx then serves /editor/ as
* a real page and 301s /editor to it, instead of both forms answering 200
* with the homepage head (Google had indexed the two as separate pages).
*/
const EditorSeoSummary: React.FC = () => (
<main>
<h1>Velxio Editor — multi-board circuit and code simulator</h1>
<p>
Write, compile and simulate Arduino, ESP32, ESP32-C3, ESP32-S3, Raspberry Pi
Pico and Raspberry Pi code in your browser, wired to a live circuit canvas
with SPICE analog simulation. Free, open source, no install and no account
needed.
</p>
</main>
);
const OSS_ROUTE_COMPONENTS: Record<string, React.FC> = {
'/editor': EditorSeoSummary,
'/examples': ExamplesPage,
};

View File

@ -47,7 +47,21 @@ export const SEO_ROUTES: SeoRoute[] = [
url: `${DOMAIN}/`,
},
},
{ path: '/editor', priority: 0.9, changefreq: 'weekly' },
{
path: '/editor',
priority: 0.9,
changefreq: 'weekly',
// With seoMeta the route is prerendered, so nginx serves /editor/ as a
// real page and 301s /editor to it. Without it both forms answered 200
// with the homepage head, and Google indexed /editor AND /editor/ as
// two pages with different impressions.
seoMeta: {
title: 'Multi-Board Simulator Editor — Arduino, ESP32, RP2040, RISC-V | Velxio',
description:
'Write, compile and simulate Arduino, ESP32, Raspberry Pi Pico, ESP32-C3, and Raspberry Pi 3 code in your browser. 19 boards, 5 CPU architectures, 48+ components. Free and open-source.',
url: `${DOMAIN}/editor`,
},
},
{
path: '/examples',
priority: 0.8,

View File

@ -1,4 +1,7 @@
import { useEffect, useRef } from 'react';
import { LOCALES, LOCALE_META, DEFAULT_LOCALE } from '../i18n/config';
import { getLocaleFromPath, stripLocaleFromPath, localizedPath } from '../i18n/path';
import { SEO_ROUTES } from '../seoRoutes';
export interface SEOMeta {
title: string;
@ -31,6 +34,53 @@ function withTrailingSlash(u: string): string {
}
}
const DOMAIN = 'https://velxio.dev';
const HREFLANG_ATTR = 'data-seo-hreflang';
/**
* Pages that exist once per locale (`/es/esp32-simulator/` renders the same
* marketing page in Spanish): the static SEO routes plus the home. Everything
* else — user projects, profiles, gallery example pages — is the same
* English content under every prefix, so it keeps a single locale-less
* canonical and no hreflang.
*/
const LOCALIZED_ROUTE_PATHS = new Set(
SEO_ROUTES.filter((r) => !r.noindex).map((r) => (r.path === '/' ? '/' : r.path.replace(/\/$/, ''))),
);
function isLocalizedRoute(strippedPath: string): boolean {
const key = strippedPath === '/' ? '/' : strippedPath.replace(/\/$/, '');
return LOCALIZED_ROUTE_PATHS.has(key);
}
/**
* Canonical + hreflang for the current URL. For a localized route the
* canonical is the page's OWN locale variant (`/es/esp32-simulator/`), and
* every locale variant is listed as an alternate with `x-default` on the
* locale-less English URL. Declaring canonical=/ from /es/ told Google the
* Spanish page was a duplicate while it was ranking on its own; this
* matches what Google already does with the pages.
*/
function localeLinks(url: string): { canonical: string; alternates: { hreflang: string; href: string }[] } {
const canonicalDefault = withTrailingSlash(url);
let strippedPath: string;
try {
strippedPath = stripLocaleFromPath(new URL(url, DOMAIN).pathname);
} catch {
return { canonical: canonicalDefault, alternates: [] };
}
if (!isLocalizedRoute(strippedPath)) return { canonical: canonicalDefault, alternates: [] };
const current = typeof window !== 'undefined' ? getLocaleFromPath(window.location.pathname) : DEFAULT_LOCALE;
const variant = (l: (typeof LOCALES)[number]) => withTrailingSlash(`${DOMAIN}${localizedPath(strippedPath, l)}`);
return {
canonical: variant(current),
alternates: [
...LOCALES.map((l) => ({ hreflang: LOCALE_META[l].htmlLang, href: variant(l) })),
{ hreflang: 'x-default', href: variant(DEFAULT_LOCALE) },
],
};
}
/**
* Updates document.title, meta description, OG/Twitter tags, and canonical
* to reflect the current page. Restores originals on unmount.
@ -79,7 +129,27 @@ export function useSEO({ title, description, url, ogImage, jsonLd, noindex }: SE
}
// Apply
const canonicalUrl = withTrailingSlash(url);
const { canonical: canonicalUrl, alternates } = localeLinks(url);
// hreflang: replace whatever the server/prerender put there (it may be
// another page's set — the shell is shared) with this page's own set.
const staleHreflang = Array.from(document.head.querySelectorAll('link[rel="alternate"][hreflang]'));
const removedHreflang = staleHreflang.map((el) => {
const clone = el.cloneNode(true) as HTMLLinkElement;
el.remove();
return clone;
});
const addedHreflang: HTMLLinkElement[] = [];
if (!noindex) {
for (const alt of alternates) {
const link = document.createElement('link');
link.rel = 'alternate';
link.hreflang = alt.hreflang;
link.href = alt.href;
link.setAttribute(HREFLANG_ATTR, '1');
document.head.appendChild(link);
addedHreflang.push(link);
}
}
document.title = title;
set(descEl, description);
if (noindex) {
@ -113,6 +183,8 @@ export function useSEO({ title, description, url, ogImage, jsonLd, noindex }: SE
if (ogImage) set(ogImgEl, origOgImg);
set(twTitleEl, origTwTitle);
set(twDescEl, origTwDesc);
for (const el of addedHreflang) el.remove();
for (const el of removedHreflang) document.head.appendChild(el);
if (createdCanonical && activeCanonical && document.head.contains(activeCanonical)) {
document.head.removeChild(activeCanonical);
} else {