senternet-site-multilingual
Add fully localized, URL-based multilingual support with prerendering, locale-correct structured data, hreflang, and SEO release gates.
SKILL.md.claude/skills/senternet-site-multilingual/SKILL.md
GitHubMultilingual / i18n Support
Add multi-language support with URL-based routing (/es/, /fr/), hreflang tags, localized prerendering, and Firebase Hosting i18n.
An indexable locale is a complete content release, not translated navigation wrapped around default-language content. Do not publish a locale prefix, prerender it, or add it to hreflang/sitemaps until every indexable route passes the translation and metadata gates below.
Step 0: Gather language requirements
Before writing any code, ask the user:
"Which languages do you want to support? English is the default (at
/). List any additional languages you want — for example: Spanish, French, German, Japanese, Portuguese, Arabic, etc. I'll generate all routes, copy scaffolding, hreflang tags, and prerender config for each one."
Wait for their response. Then derive a locale config table like this (fill in based on their answer):
| Language | Code | URL prefix | RTL? |
|---|---|---|---|
| English | en |
/ |
No |
| Spanish | es |
/es |
No |
| French | fr |
/fr |
No |
| Arabic | ar |
/ar |
Yes |
| … | … | … | … |
Use ISO 639-1 codes. For RTL languages (Arabic, Hebrew, Persian, Urdu), note them — they need dir="rtl" on <html>.
Show the table to the user and confirm before proceeding. Example:
"Got it — I'll add support for English (default), Spanish (
/es), and French (/fr). Proceeding with implementation."
Architecture decision
This implementation uses:
- URL prefix routing: English at
/, all other locales at/{code}/(not?lang=esquery params) - React Context for current language state
- A single
i18n.tsfile with all copy for all locales (not separate JSON files) window.__LANG__flag so the prerender script can force a specific locale during static generation- hreflang
<link>tags in the prerendered HTML so Google serves the right language - One locale-aware URL helper shared by canonicals, hreflang, internal links, sitemap entries, and structured data
- A publication gate that keeps incomplete locale scaffolding out of indexable output
Steps
1. Create src/i18n.ts
Build the file using the confirmed language list. The Language type must include every confirmed code. The copy object must have a key for every locale. The languageNames map must list every locale.
import { createContext, createElement, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
// Replace with the union of all confirmed language codes, e.g. 'en' | 'es' | 'fr'
export type Language = 'en' | '<code2>' | '<code3>';
// All locale codes except English, used for URL prefix detection
const NON_DEFAULT_LOCALES: Language[] = ['<code2>', '<code3>'];
export interface LocaleCopy {
languageLabel: string;
languageNames: Record<Language, string>;
nav: {
home: string;
about: string;
};
home: {
meta: { title: string; description: string };
hero: { headline: string; body: string; cta: string };
// Add all copy keys for every page
};
// Add all pages
}
const copy: Record<Language, LocaleCopy> = {
en: {
languageLabel: 'Language',
languageNames: {
en: 'English',
// Add native name for each additional locale, e.g. es: 'Español', fr: 'Français'
},
nav: { home: 'Home', about: 'About' },
home: {
meta: {
title: 'APPNAME - Tagline',
description: 'Description for search results.',
},
hero: {
headline: 'Your headline here',
body: 'Supporting copy here.',
cta: 'Get started',
},
},
},
// Repeat for every additional locale with complete, reviewed translations.
// Never publish placeholder or default-language fallback strings at locale URLs.
'<code2>': {
languageLabel: '…', // "Language" in this language
languageNames: { en: 'English', '<code2>': '…' },
nav: { home: '…', about: '…' },
home: {
meta: { title: 'APPNAME - …', description: '…' },
hero: { headline: '…', body: '…', cta: '…' },
},
},
};
const LanguageContext = createContext<{ language: Language; setLanguage: (l: Language) => void }>({
language: 'en',
setLanguage: () => {},
});
// All confirmed locale codes (including 'en') — keep in sync with ALL_LOCALES in MetaTags
const ALL_LOCALES: Language[] = ['en', ...NON_DEFAULT_LOCALES];
function detectLanguage(): Language {
// 1. Prerender script injects window.__LANG__ before navigating to locale routes
if (typeof window !== 'undefined' && (window as any).__LANG__) {
return (window as any).__LANG__ as Language;
}
// 2. URL prefix — explicit locale in the path takes priority over any stored preference
if (typeof window !== 'undefined') {
for (const code of NON_DEFAULT_LOCALES) {
if (window.location.pathname.startsWith(`/${code}`)) return code;
}
}
// 3. Explicit user override stored in localStorage by the language switcher
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('lang') as Language | null;
if (stored && (ALL_LOCALES as string[]).includes(stored)) return stored;
}
// 4. Browser Accept-Language preference (navigator.languages mirrors the header)
if (typeof window !== 'undefined' && navigator.languages?.length) {
for (const lang of navigator.languages) {
const code = lang.split('-')[0] as Language;
if ((NON_DEFAULT_LOCALES as string[]).includes(code)) return code;
}
}
return 'en';
}
export function LanguageProvider({ children }: { children: ReactNode }) {
const [language, setLanguage] = useState<Language>(detectLanguage);
useEffect(() => {
const newLang = detectLanguage();
if (newLang !== language) setLanguage(newLang);
}, [typeof window !== 'undefined' ? window.location.pathname : '']);
return createElement(LanguageContext.Provider, { value: { language, setLanguage } }, children);
}
export function useLanguage() {
return useContext(LanguageContext);
}
export function useCopy(): LocaleCopy {
const { language } = useLanguage();
return useMemo(() => copy[language], [language]);
}
2. Update src/App.tsx for locale routing
Add a route group for every non-default locale. English routes stay at root.
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { LanguageProvider } from './i18n';
function App() {
return (
<LanguageProvider>
<BrowserRouter>
<Routes>
{/* English (default) routes */}
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
{/* Repeat the block below for every additional locale */}
<Route path="/es" element={<HomePage />} />
<Route path="/es/about" element={<AboutPage />} />
<Route path="/fr" element={<HomePage />} />
<Route path="/fr/about" element={<AboutPage />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</BrowserRouter>
</LanguageProvider>
);
}
3. Use copy in page components
No change needed in component logic — useCopy() returns the right locale automatically.
import { useCopy } from '../i18n';
function HomePage() {
const t = useCopy();
return (
<>
<MetaTags title={t.home.meta.title} description={t.home.meta.description} />
<h1>{t.home.hero.headline}</h1>
<p>{t.home.hero.body}</p>
</>
);
}
Apply the same rule to every indexable route and every content source, not only top-level UI components:
- article titles, summaries, dates, headings, body paragraphs, lists, captions, and image alt text
- project detail metadata, introductions, sections, pricing labels, CTAs, and related links
- About, legal, tool, comparison, landing, tag, and pagination pages
- strings stored in route registries, post/project data modules, component constants, and schema builders
Do not leave POST_TITLE, POST_DESCRIPTION, project data, or article bodies hardcoded in the default language while only translating the surrounding template. If a route has long-form content, model that content in the locale layer or a locale-specific content module and render it from the active locale.
3a. Gate incomplete locales before creating URLs
Maintain an explicit coverage matrix of route IDs × locales. A locale is publishable only when every indexable route has complete visible copy, metadata, image text, and schema labels.
If translations are incomplete:
- keep the locale disabled in the published locale list
- do not register its routes, prerender them, add hreflang alternates, or emit sitemap URLs
- do not publish fallback English bodies under locale-prefixed URLs
- do not use cross-locale canonicals or
noindexas a substitute for finishing the translation
It is fine to scaffold an unpublished locale behind a separate development-only list, but the production PUBLISHED_LOCALES list must contain only complete locales.
4. Add hreflang links in MetaTags (or usePageHead)
Generate one <link rel="alternate"> per locale plus x-default. Build the list dynamically from the confirmed locale codes.
// All confirmed locale codes (including 'en')
const ALL_LOCALES: Language[] = ['en', '<code2>', '<code3>'];
const origin = import.meta.env.VITE_BASE_URL || window.location.origin;
// Strip any locale prefix to get the canonical English path
const stripLocale = (path: string) => {
for (const code of NON_DEFAULT_LOCALES) {
if (path.startsWith(`/${code}`)) return path.slice(code.length + 1) || '/';
}
return path;
};
const englishPath = stripLocale(currentPath);
ALL_LOCALES.forEach((code) => {
const href = code === 'en'
? `${origin}${englishPath}`
: `${origin}/${code}${englishPath === '/' ? '' : englishPath}`;
setAlternate(code, href);
});
setAlternate('x-default', `${origin}${englishPath}`);
Use this same localized canonical URL in structured data. Every localized page's page-specific @id, url, mainEntityOfPage, and breadcrumb item must reference the localized canonical, not the English URL. Global entities such as the author Person or publisher Organization may keep one stable site-wide @id.
const canonicalPath = localizePath(basePath, language);
const canonicalUrl = `${origin}${canonicalPath}`;
const schema = {
'@type': 'BlogPosting',
headline: t.title,
description: t.description,
mainEntityOfPage: { '@type': 'WebPage', '@id': canonicalUrl },
};
const breadcrumbs = [
{ '@type': 'ListItem', position: 1, name: t.home, item: `${origin}${localizePath('/', language)}` },
{ '@type': 'ListItem', position: 2, name: t.blog, item: `${origin}${localizePath('/blog', language)}` },
{ '@type': 'ListItem', position: 3, name: t.title, item: canonicalUrl },
];
5. Update the prerender script for locales
Build the LOCALES array from the confirmed list and loop over all routes × all locales.
// One entry per locale. English has an empty dir prefix.
const LOCALES = [
{ language: 'en', dir: '' },
{ language: '<code2>', dir: '<code2>' },
{ language: '<code3>', dir: '<code3>' },
// Add one entry per additional locale
];
for (const locale of LOCALES) {
// Clear stale locale output before re-rendering
if (locale.dir) {
fs.rmSync(path.join(buildDir, locale.dir), { recursive: true, force: true });
}
for (const route of ROUTES) {
const page = await browser.newPage();
if (locale.language !== 'en') {
await page.evaluateOnNewDocument((lang) => { window.__LANG__ = lang; }, locale.language);
}
const requestPath = locale.dir
? `/${locale.dir}${route === '/' ? '' : route}`
: route;
await page.goto(`http://localhost:9999${requestPath}`, { waitUntil: 'networkidle2' });
// ... render and save HTML
const outDir = locale.dir
? path.join(buildDir, locale.dir, route === '/' ? '' : route.slice(1))
: (route === '/' ? buildDir : path.join(buildDir, route.slice(1)));
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'index.html'), html);
}
}
6. Update the sitemap with hreflang alternates
See the senternet-site-sitemap skill — the LOCALES array drives <xhtml:link> alternates automatically. Pass the same locale list.
7. Language switcher component
Build the switcher from the locale list — do not hardcode a binary EN↔ES toggle.
import { useLanguage, type Language } from '../i18n';
const LOCALE_LABELS: Record<Language, string> = {
en: 'English',
'<code2>': '…', // native name
'<code3>': '…',
};
const NON_DEFAULT_LOCALES: Language[] = ['<code2>', '<code3>'];
function LanguageSwitcher() {
const { language, setLanguage } = useLanguage();
const switchTo = (lang: Language) => {
localStorage.setItem('lang', lang);
setLanguage(lang);
const current = window.location.pathname;
// Strip any existing locale prefix
const basePath = NON_DEFAULT_LOCALES.reduce(
(p, code) => (p.startsWith(`/${code}`) ? p.slice(code.length + 1) || '/' : p),
current
);
const newPath = lang === 'en' ? basePath : `/${lang}${basePath === '/' ? '' : basePath}`;
// Locale pages are distinct prerendered documents. Force a document navigation so
// the browser loads and hydrates the target locale's HTML and metadata.
window.location.assign(newPath);
};
return (
<select value={language} onChange={(e) => switchTo(e.target.value as Language)}>
{(Object.entries(LOCALE_LABELS) as [Language, string][]).map(([code, label]) => (
<option key={code} value={code}>{label}</option>
))}
</select>
);
}
8. RTL support (if applicable)
For any RTL locale (Arabic, Hebrew, Persian, Urdu), add dir to <html> based on the active language:
const RTL_LOCALES: Language[] = [/* list RTL codes here, e.g. 'ar', 'he' */];
useEffect(() => {
document.documentElement.dir = RTL_LOCALES.includes(language) ? 'rtl' : 'ltr';
document.documentElement.lang = language;
}, [language]);
9. Add multilingual SEO regression tests
Add tests that inspect the production prerender output, not only React source. For every published route × locale, assert:
- the output file exists and contains meaningful rendered content
<html lang>anddirmatch the locale (zh-Hansis preferred for Simplified Chinese)- the canonical is the page's own locale URL
- the page has exactly one H1 plus non-empty localized title and description
- the hreflang set contains every published locale, itself, and
x-default, with identical reciprocal targets - every hreflang target exists in the build and is indexable
- JSON-LD parses and page-specific
@id,url,mainEntityOfPage, and breadcrumbs use the localized canonical - the localized main-content text is not byte-for-byte or normalized-text identical to the English version
- route/content coverage has no fallback default-language values for published locales
- title-length checks flag likely truncation for human review rather than silently accepting oversized translated titles
Make the tests enumerate route/content registries so adding a new post, project, or locale automatically expands coverage. A test that checks only one sample route is insufficient.
After the build, crawl the full generated sitemap and fail the task if any URL redirects, 404s, cross-canonicalizes, omits a return hreflang, has invalid JSON-LD, or contains untranslated main content.
Notes
- All visitor-facing copy must come from a locale-aware source — never hardcode default-language page content in components
- Every published locale needs a complete entry for every indexable route; TypeScript shape checks are necessary but do not prove that fallback English values are absent
- Placeholder translations are development scaffolding only and must never enter
PUBLISHED_LOCALES - The prerender script must clear each locale's output directory before re-rendering to avoid stale pages
- Firebase Hosting's
"i18n": { "root": "/" }config is optional — URL prefix routing handles i18n at the app level - After adding translations: run the full production build and the multilingual SEO regression suite, then inspect representative deep pages in each locale
Language detection priority
detectLanguage() resolves the active locale in this order:
window.__LANG__— injected by the prerender script; always wins- URL prefix —
/es/,/fr/, etc.; takes precedence over any stored preference so that shared locale links always work localStorage.lang— written by the language switcher; persists explicit user choice across sessionsnavigator.languages— browser's Accept-Language preference; first matching supported locale wins'en'— hardcoded default
The language switcher writes localStorage.lang before navigating, so a user who picks a language at / is remembered on their next visit even if they land on / again.
Framework: Next.js track
The URL structure and hreflang policy are unchanged (/es/... prefixes, x-default pointing at English). The mechanism is the router, not a context provider plus prerender loop.
- Move routes under a dynamic segment:
app/[locale]/page.tsx,app/[locale]/about/page.tsx. ExportgenerateStaticParams()returning the locale list so every locale is rendered statically at build time. - Use a library built for the App Router (e.g.
next-intl) for message loading and locale detection, rather than theLanguageProviderpattern. Do not addi18nextwired through a client context — it forces the whole tree client-side. - Locale detection and redirects go in
middleware.ts. hreflangcomes fromalternates.languagesin each page'smetadata, and must match the alternates emitted byapp/sitemap.ts. Set both from the same locale list.- Localize page-specific JSON-LD URLs and breadcrumb items from the same canonical URL helper used by
metadata.alternates. - Generate static params and sitemap entries only from
PUBLISHED_LOCALES; incomplete locale message files must not create public routes. - Extend
config/routes.mjswith the locale prefixes so the sitemap and IndexNow cover every localized URL. - There is no
/es/loop to add to a prerender script, because there is no prerender script.
See /senternet-site-framework for the full convention map.