Migrating to Bilingual with hreflang and SDD: The Guide That Would Have Saved Me Three SEO Bugs

A few weeks ago I finished migrating a site that had its content mixed within the same HTML —literally, paragraphs in Spanish followed by fragments in English coming from different components depending on which part of the store was hydrated first— to a real bilingual structure, with separate routes, reciprocal hreflang, and a sitemap with alternates. In this article I tell how I approached it in my role as Forward Deployed Engineer: what went wrong, what decisions I made, and why working with SDD (spec, plan, tasks, verification) helped me avoid breaking the ranking the site already had.
This isn't a generic i18n tutorial: I share what actually happened, in the order it happened, in case it can help other teams in a similar situation.
The starting point: an HTML that didn't know what language it was in
The symptom that kicked all this off was simple to describe and hard to diagnose: some page loads showed the hero in Spanish and the footer in English. Others, the other way around. It wasn't a missing-translation problem, it was worse: the server was rendering with a global store that was populated asynchronously and didn't guarantee that all components read the same language on the same render.
One takeaway from this: i18n problems are rarely "a translation is missing." They're almost always problems of when and where the language is decided, and whether that decision is consistent throughout the entire render lifecycle.
Before touching a single line of component code, I wrote the spec: what behavior the site had to have in each language, what URLs would exist, what would happen to the ones that were already indexed. That spec became a plan with concrete tasks, and each task had an explicit verification criterion. I didn't do it for bureaucracy's sake: when touching routes that Google has already indexed, every unverified change is a real risk of lost traffic.
Routes under app/[lang]: static first, always
The architecture decision was to move all the content to routes under app/[lang]/..., using generateStaticParams so that each language/page combination would be generated as static HTML at build time, not on every request.
This mattered for two reasons. The first is performance: I didn't want to introduce runtime language detection that would force dynamic SSR on pages that used to be static. The second is predictability: if generateStaticParams explicitly returns the ['es', 'en'] combinations for each slug, there's no possible ambiguity about which version exists and which doesn't. The build fails if a translation is missing, instead of silently failing in production with some weird fallback.
In practice, this meant restructuring the route tree so that each page had its own content file per language, and having generateStaticParams read that list of available content instead of assuming everything was translated. Any page without its counterpart in the other language was left out of the build for that language, instead of showing half-baked content.
Spanish at the root: the rewrite that avoided breaking indexed URLs
This is the part that would have given me the most trouble without the prior spec. The site already had years of history in Spanish, indexed at the root (/, /articulo-x, with no language prefix). If I migrated everything to /es/... and /en/... literally, I'd break every URL Google already knew about. That's not a minor detail: it's existing organic traffic put at risk by an architecture decision.
The solution was to keep Spanish as the "default" language visible at the root, with no prefix, and resolve that ambiguity in the proxy with a rewrite: internally, any request to /articulo-x is rewritten to /es/articulo-x so the app's router can resolve it against the app/[lang] structure, but the public URL —the one the user sees and the one Google indexes— stays /articulo-x. English, on the other hand, does live with an explicit prefix: /en/articulo-x.
This means that [lang] as a route segment always exists at the application level, but doesn't always exist at the public URL level. The rewrite in the proxy is the layer that translates between those two realities. It was one of the plan's tasks that needed the most verification time, because a misconfigured rewrite doesn't break in development —it breaks in production, with CDN caches involved, and by then it's too late.
Why I didn't detect the browser language (and why I almost did)
The obvious temptation here is to use Accept-Language or some kind of browser language detection to automatically redirect each visitor to their preferred language. I considered it in the initial plan and discarded it, and the reason is concrete: Googlebot browses in English. If the site detects the browser language and redirects based on that, the crawler consistently sees the English version, regardless of which URL you asked it to crawl. That breaks the possibility of Google correctly indexing the Spanish version of each page, because it never sees it as it actually is: it always sees it redirected.
This isn't a quirk specific to one bot but a general pattern: any language logic based on request headers instead of on the URL itself introduces a divergence between what a crawler sees and what a human user with their browser set to another language sees. And that divergence is exactly what hreflang is designed to prevent, not to create.
The final decision was that the language is determined exclusively by the requested URL, period. No heuristics, no content negotiation, no client-side JavaScript redirecting after the first render. Every URL is a fixed source of truth about what language that content is in, and that was precisely the guarantee I needed for hreflang to work reliably.
The global store that mixed languages: the trap that wasn't visible in the code
This was the most uncomfortable part to diagnose, and the reason it was worth having explicit verification tasks in the plan, not just implementation tasks.
The app's global store —the one that handles UI state, theme, some preferences— also had a reference to the current language, populated in an effect that ran after the initial render. On the client this was invisible: by the time the user saw anything, the store was already consistent. But in the HTML generated on the server, different components could render at moments when the store hadn't yet finished being populated with the correct language, or when a concurrent request had left the store with the language from a previous request if something shared state between renders improperly.
The solution wasn't "fix the store," it was to eliminate that dependency: the language stopped living in a global store and started being derived directly from the [lang] segment of the route in each component that needed it, via each request's server context, with no state shared between renders. No server component should ask the store "what language are we in?": it should receive it as part of that specific request's data.
In my experience, this is the kind of bug that an SDD cycle helps catch better than ad hoc development: the verification task explicitly said "render 20 concurrent requests in both languages and confirm that each one's HTML corresponds to its requested language." Without that criterion written down beforehand, it's easy to assume "it already works" from a single manual load in the browser, which is exactly the scenario where the bug didn't show up.
Canonical, reciprocal hreflang, and the sitemap with alternates
With the routes resolved and the language now deterministic based on URL, the rest was correctly implementing the signals for search engines:
- Each Spanish page declares its own
canonicalpointing to its unprefixed URL, and anhreflang="en"pointing to its equivalent at/en/.... - Each English page does the symmetric thing:
canonicalto its own prefixed URL, andhreflang="es"pointing back to the unprefixed version. - I added
hreflang="x-default"pointing to the Spanish version, because that's the one that lives at the root and has the longer indexing history.
The non-negotiable point here is that hreflang tags have to be reciprocal: if the Spanish page declares that its English counterpart is URL X, that URL X has to declare back that its Spanish counterpart is the original URL. A non-reciprocal hreflang is, for practical purposes, ignored by Google. This was verified as a separate task in the plan, iterating over each pair of pages and confirming reciprocity in an automated way, not by reading the HTML by hand.
The sitemap was generated including the language alternate tags for each entry, not as two separate sitemaps but as a single sitemap where each URL lists its corresponding alternates. The final result, after the whole migration, was a sitemap with 32 URLs: 5 pages and 11 articles, each in its two languages.
Why the SDD cycle mattered here
None of these traps —the miscalculated rewrite, browser-based language detection, the global store mixing languages, the non-reciprocal hreflang— is exotic. They're well-known errors in any i18n project. What changed the outcome wasn't knowing the theory beforehand (in several cases I discovered it along the way), but having structured the entire work as a cycle with spec, plan, tasks, and explicit verification in production, instead of implementing and testing manually as I went.
Every task had a success criterion written before touching code, and that criterion was verified against real production, not against a local environment that doesn't replicate CDN caches, proxy rewrites, or crawler behavior. That was what made it possible to detect, for example, that the global store worked "fine" in development but failed under real concurrency, or that a rewrite that looked correct locally behaved differently behind the proxy layer in production.
The migration ended up, in concrete numbers, going from an HTML with mixed languages and ambiguous indexing to a site with 32 well-declared bilingual URLs in the sitemap, each with its corresponding canonical and reciprocal hreflang. All the work, end to end, was done within a single SDD cycle, with production verification as a non-optional part of the process.
What I took away
- i18n problems are usually about when and where the language is decided, not about missing translations.
- Making the language depend only on the URL keeps behavior predictable for users and crawlers alike.
- Writing the spec and verification criteria before touching code helped me protect URLs that were already indexed.
- Verifying against real production caught problems that the local environment didn't show.
- I discovered several of these traps along the way: having a process that forces verification is what kept them in check.
If you're working on something similar, such as bilingual routes, hreflang, or migrations that can't afford to break indexed URLs, I'd be glad to exchange experiences: you can write to me on LinkedIn. If it's useful, in my portfolio I describe how I work on this kind of project: https://estebanburgos.com.ar/portfolio.

About the author
ESTEBAN BURGOS · Forward Deployed Engineer
I build software end to end for startups and companies: websites, platforms and AI solutions. I write about what I learn on real projects.
See my backgroundWant something like this for your company?
Tell Tuki about your idea and get a price range in your currency within minutes.
Keep reading
- From loose AI to agentic development: how we adopted SDD in a team at YPFHow I proposed adopting Spec-Driven Development in a YPF development team and how we implemented it together: specs, cycles, role-based agents and gates, which flows we used by change size, and what we learned.
- How I built Tuki: the RAG-powered quoting tool that's already in productionA first-hand account of Tuki, the RAG-powered quoting assistant running in production at estebanburgos.com.ar/quote. I go over the architecture decisions, why I chose pgvector without an HNSW index, how I keep costs under control with rate limiting, and what broke along the way.
- Microfrontends in the Agentic Era: Orchestration, SDD and the Beauty of the Monorepo with NxDiscover how microfrontends are transforming software development in the era of shared, SDD-driven agentic systems. We explore the flexibility of building interfaces as independent apps or as apps co-located with their BFFs in a monorepo, orchestration with Nx, and the efficiency of shared code through libraries: a paradigm well worth admiring.
Comments
Be the first to comment.