I work 100% remotely, with clients in any country, and until recently this site only spoke Spanish. Now all of it is in English, Lab demos included (DEC-024, in Spanish). A few things stay out: the /admin/ panel, which only I use, and the PDF résumé and the certificates, which are in Spanish (the English page says so). The English privacy policy is a courtesy translation: the Spanish one prevails.
Here’s how it’s built, because translating was the easy part. The hard part was keeping the two versions from drifting apart.
No Spanish URL changed
Spanish stays at the root, with the same URLs as always, and English lives under /en/ with translated paths. The fixed pages are in one table:
export const ROUTES = {
home: { es: '/', en: '/en/' },
services: { es: '/servicios/', en: '/en/services/' },
projects: { es: '/proyectos/', en: '/en/projects/' },
lab: { es: '/lab/', en: '/en/lab/' },
quote: { es: '/cotizar/', en: '/en/quote/' },
Detail pages (a service, a project, a demo, this post) take their slug from each language’s content file: /servicios/paginas-web/ is /en/services/websites/. I passed on the simplest option, repeating the Spanish paths under /en/, because an English-speaking visitor would see /en/servicios/.
One view, two thin routes
Each page lives in a single view, src/views/<Name>View.astro, which takes lang and builds everything. The routes just call it: src/pages/privacidad.astro is <PrivacyView lang="es" />, and src/pages/en/privacy.astro is the same with lang="en". On detail pages, the route takes the slugs of its language and hands the view the entry’s stable id, never the slug, which changes with the language:
export const getStaticPaths = () => detailStaticPaths('services', 'en')
const { id } = Astro.props
English is a layer over Spanish
The content (services, projects, the quote calculator…) lives in src/content/. English is in src/content/en/, with the same file names and only the texts and the slugs. The English websites service has no price: it inherits the Spanish one, so a price is changed in one place.
The rules are in merge.ts. Objects merge key by key, and a key that doesn’t exist in Spanish is an error, so a typo doesn’t slip by silently. Lists keep the same length and order, and when items have an id, it has to match. Anything that isn’t text stays untouched:
if (kind(overlay) !== kind(base)) throw fail(`expected ${kind(base)}, found ${kind(overlay)}`)
if (typeof base !== 'string' && overlay !== base) {
throw fail(`must stay ${JSON.stringify(base)} as in the Spanish file (only texts are translated)`)
}
The result is validated with the same schema as the Spanish content: a bad file breaks the build, and the error names the file and the field. On top of that, src/i18n/content.test.ts fails if a Spanish file is missing its English version. The blog is the exception (DEC-025, in Spanish): translating a post is optional.
Interface texts, checked by the compiler
The menu, buttons and footer are in es.ts and en.ts. en.ts has the Messages type, which comes from the shape of es.ts: a missing or extra key fails pnpm typecheck. Each island keeps its texts in a messages.ts, with the same check:
export function defineMessages<T extends object>(texts: {
es: T
en: NoInfer<Widen<T>>
}): Record<Lang, Widen<T>> {
Widen turns the Spanish string literals into string: the English can say something else, but with the same keys. Functions are kept, so if a Spanish text takes a number, the English one does too.
hreflang, canonical and sitemap
Each view passes Base.astro an alternate: the path of the same page in the other language. With it, the layout writes the hreflang links:
const hreflang = versions
? [
{ hreflang: LOCALES.es.htmlLang, href: versions.es },
{ hreflang: LOCALES.en.htmlLang, href: versions.en },
{ hreflang: 'x-default', href: versions.es },
]
: []
x-default points to Spanish, and without an alternate (like on /admin/) there’s no hreflang. Every page also has its own canonical and its og:locale (es_CO or en_US). The sitemap skips the i18n option of @astrojs/sitemap, because it assumes the same path after /en/, and here paths are translated: it lists both versions, and the pairs live in the HTML. e2e/i18n.e2e.ts checks every page of the build and, when its translation exists, that it points back. With translated slugs, that’s an easy place to get wrong.
A notice, not a redirect
I don’t redirect based on the browser’s language. The site is static and search engines need to see both versions; a redirect would need a function on every visit. Instead, if your browser prefers the other language and the page exists in it, a notice appears, fixed at the bottom (suggest.ts). It moves nothing (the E2E test measures a layout shift of 0) and it never takes the focus. If you close it or switch languages, the choice is saved in localStorage under the key aim-lang, and the notice doesn’t come back. If the browser blocks storage, everything still works: the notice just shows up again.
No broken links, in either language
With translated paths, a link to /en/servicios/ is easy to write and hard to spot. scripts/check-links.mjs reads all the HTML of the build and checks that every internal href, src and srcset exists, canonical and hreflang links included. It failed while English pages were missing; once they were all there, it joined the CI, right after the build:
pnpm build
step "enlaces internos del build"
The demos speak English too
- Live automation runs a workflow designed in n8n. Its «Reglas del flujo» (“workflow rules”) node now keeps the texts per language, in
texts.esandtexts.en, and a new node, «Textos del idioma» (“language texts”), picks the lead’s. I recorded real n8n again with 18 leads, 6 of them in English: the 11 Spanish ones from before gave exactly the same result, and the parity test requires accepted and rejected cases in both languages. - The APIs return a code with every error, and on English pages each island writes the message with its
messages.ts. The ones on Render also answer in the language thatAccept-Languageasks for. - The Data migrator stopped returning Spanish sentences: each problem in a cell is a
codewith itsparams(FieldErrorin@aim/contracts), the same in the browser engine and the Python one, and the page writes the explanation. The cost: the website and the Python engine are deployed together, because the new site doesn’t accept the old response.
Check it yourself
- The decisions and what I ruled out:
docs/DECISIONES.md(DEC-024 and DEC-025, in Spanish). - The guide to adding a page in both languages:
docs/IDIOMAS.md(in Spanish). - The translation rules and their tests:
merge.tsandmerge.test.ts. - The workflow’s parity in both languages:
automations/test/parity.test.ts.
And to see it working, open the quote calculator and switch languages from the header: the prices stay the same, because they come from the same file.