Skip to main content
Back to notes

Bilingual SEO for a React and Vite website

Still Brewing UAPublished: Updated:

A language toggle that only swaps text in the browser gives search engines one URL and one version of the content. Two indexable languages need two URLs, each serving finished HTML in its own language. Here is how that is put together without a framework.

The URL is the language

Each language gets its own path: one under `/en/`, one under `/uk/`. That path is the single source of truth. A stored preference may decide where a first-time visitor lands, but it must never override a language that is already stated in the URL.

The rule matters in practice. If someone shares a link to the Ukrainian page and it opens in English because of a value in the visitor’s browser, the link is broken for everyone who receives it — and a crawler that saved a preference would index the wrong content.

Resolution order
1. an explicit value (used when prerendering)
2. the language segment in the URL   <- always wins
3. the stored preference             <- only on the "/" fallback
4. the default language

Serve finished HTML, not an empty shell

A single-page app normally ships an empty container and fills it with JavaScript. Search engines can execute JavaScript, but there is no reason to make indexing depend on it when the content is known at build time.

After the client build, render the app once per URL and write the result into the HTML file for that URL. Each file then contains the real text of its language before a single script runs. No framework is needed: Vite can load the app on the server side during the build, and React can render it to a static string.

One detail is easy to miss. Content that fades in on scroll starts hidden; if the prerender captures that initial state, the static HTML contains text that is present but invisible. Render the revealed state when there is no browser, and the file a crawler downloads shows the finished page.

Per-language metadata

Every language version needs its own title, description and social metadata. Building them from the same translation bundle that renders the page keeps them in sync automatically — the metadata cannot drift from what the visitor reads.

  • `<html lang>` matching the page language
  • a distinct `<title>` and meta description per language
  • a self-referencing canonical URL
  • `og:title`, `og:description`, `og:url` and `og:locale`
  • Twitter card title and description
  • a localized Open Graph image and its alt text

If the language can also be switched inside the page, update the head at the same time. Otherwise the tab keeps the previous language’s title after a switch, and anything reading the live DOM sees a page whose URL and metadata disagree.

Canonical and hreflang

Each page points its canonical at itself. Language versions are alternates of each other, not duplicates of one original, so neither should be canonical to the other.

Both versions then carry the same set of mutual alternates, including `x-default` for visitors whose language you do not cover.

The same block on both pages
<link rel="canonical" href="https://example.com/en/" />
<link rel="alternate" hreflang="en" href="https://example.com/en/" />
<link rel="alternate" hreflang="uk" href="https://example.com/uk/" />
<link rel="alternate" hreflang="x-default" href="https://example.com/en/" />

The alternates must be reciprocal. A page that lists an alternate which does not point back is ignored.

Use `uk`, not `ua`

The language code for Ukrainian is `uk`. `UA` is the country code for Ukraine, and the two are not interchangeable. `hreflang="ua"` is invalid and is discarded.

Using the same code for the URL segment, the `lang` attribute and the hreflang value keeps this consistent by construction — there is only one place where the code is written down.

What to do with the root URL

The root can stay useful without competing for indexing: serve the default language there and point its canonical at that language’s real URL, then redirect it at the server once you are happy with the setup. Either way, keep the root out of the sitemap so it is never offered as a separate page.

Sitemap and robots.txt

List every indexable URL once, and give each entry the same set of language alternates that the page itself carries. Generating the file from the same site URL value used by the build means the sitemap can never point at a stale domain.

One entry with its alternates
<url>
  <loc>https://example.com/en/</loc>
  <xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/"/>
  <xhtml:link rel="alternate" hreflang="uk" href="https://example.com/uk/"/>
  <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/en/"/>
  <lastmod>2026-09-02</lastmod>
</url>

The `xhtml` namespace has to be declared on the `urlset` element or the alternates are not read. `robots.txt` then points at the sitemap using the same absolute origin.

Search Console

Once the URLs are live, verify the site and submit the sitemap. The URL inspection tool shows the HTML that was actually fetched, which is the quickest way to confirm that the prerendered content — not an empty shell — is what reaches the crawler, and that each page reports the canonical you intended.

Verify each language directly

Open each language URL directly rather than reaching it through the toggle, and check the served source rather than the rendered DOM. The two can differ, and the served source is what indexing starts from.

  • each URL returns 200 and the correct `<html lang>`
  • the source contains real text in that language, not a placeholder
  • neither page contains sentences from the other language
  • canonical and hreflang are absolute and reciprocal
  • a stored preference does not override the URL
  • switching language keeps the current page, its query and its hash

Want something like this built?

These notes come from work I have shipped. If you need the same done properly, tell me what you have in mind.