Guides · Node.js & Express · Published 2026-09-13 · 3 min read

A Multi-Language Express Site With hreflang — Root Language Plus /th, /sg Prefixes, Automatic Alternates, and a Sitemap That Search Engines Accept

How to structure a small Express and EJS site so that English lives at the root, other languages under a prefix, every page declares its alternates with hreflang and x-default, and the sitemap lists them, using a single config file and one content folder per language.

A site that will eventually serve several languages is much easier to build multilingual from day one than to retrofit. The pattern below is what we use for our content sites: one language at the root, others under a short prefix, a content folder per language, and hreflang generated automatically wherever two languages share a slug. It needs no i18n library.

The URL scheme

Language Home Article Why
Primary (English) / /p/<slug> Root gets the domain's authority; no redirect for the main audience
Others /th/, /sg/ /th/p/<slug> A path prefix, not a subdomain, so one deploy, one certificate, shared authority

The list of languages lives in a single config: code, locale for <html lang>, a display label, and the prefix, with the first entry being the root language. Routes are registered in a loop over that list, so adding a language is a config line and a content folder.

Content per language

Articles are Markdown files with front matter under content/<lang>/<slug>.md. The slug is the file name. A translation is simply the same file name in another language folder. That one convention drives everything else: the alternates for a page are the set of language folders in which the same file exists, and the reading-time estimate can switch from word counting to character counting for languages without spaces. See a Markdown blog with Express and EJS for the loader itself.

Generating hreflang

For each rendered page, look up the same slug in every language folder. Emit one <link rel="alternate" hreflang="<locale>" href="<absolute url>"> per language where it exists, including the current one, plus an x-default pointing at the root-language version, or at the current page if no root version exists. Three rules keep search engines happy: every URL absolute with the canonical host, every pair reciprocal (if the Thai page lists the English one, the English page must list the Thai one; the shared-slug rule guarantees this), and no alternates at all when a page has no translation, rather than a self-referencing pair.

The sitemap

One sitemap.xml for the whole site, listing each language's home, category pages and articles. For pages with alternates, include the xhtml:link alternate entries inside each <url>, mirroring the hreflang tags. Search Console reads these and reports the alternates under the page's URL inspection, which is the quickest way to confirm the tags are correct. Submitting the sitemap is described in Cloud Run custom domains with Cloudflare DNS.

UI strings and categories

Keep interface strings in a T[lang] object in the config: navigation labels, "read more", category names. Categories are defined once with a key and a colour, and each language provides the display name in its T block. The templates never contain literal text; they read from t, which the route passes for the current language. Dates format with toLocaleDateString(locale).

Things that go wrong

Summary

Put the primary language at the root and the rest under prefixes, keep one content folder per language with identical slugs for translations, and generate hreflang and sitemap alternates from that shared-slug rule. A single config file describes the languages, and adding one later is a folder and a line.

Related guides