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.
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 languageServe 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.
<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.
<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
Sources
Related notes
- Deploying a React and Vite site to Nginx with versioned releasesHow to ship a static React and Vite site to an Nginx server using versioned release directories, checksum verification and an atomic symlink switch.Read the note
- A safe checklist for deploying a static site on a shared serverA practical checklist for adding a static site to a server that already runs other projects, without touching anything that was already working.Read the note
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.