MonkeyLoader All articles
Infrastructure

A Guest, Not a Stowaway: Loading Welsh Beside English

MonkeyLoader
A Guest, Not a Stowaway: Loading Welsh Beside English

Photo: Colin Smith , CC BY-SA 2.0, via Wikimedia Commons

Most visitors to a council, health board or university site in Wales will read English. A meaningful minority will want Welsh, and the Welsh Language Standards say they should get it without a fight. The lazy option is to bundle both language packs and let JavaScript pick one at runtime. That is how a 180 KB page becomes 300 KB before anyone has read a word. The better option is a small loader that treats the second language as a guest: welcome when invited, not sleeping in the hallway.

At MonkeyLoader we care about what actually gets sent down the wire, so this is not a piece about translation quality. It is about the plumbing that lets a bilingual site behave itself.

Start with one shell, not two sites

The tempting architecture is two parallel sites: English under /en/, Welsh under /cy/, each with its own bundle. That works, but it doubles your build output and makes shared components drift. A leaner pattern is one application shell with two string packs. The shell contains layout, routing, components and the default language strings. The non-default pack is a separate, hashed JSON file that only gets fetched when a visitor asks for it.

That means an English page renders English content on the server and sends no Welsh strings at all. A Welsh page does the reverse. The loader's job is to know which pack is already active and to fetch the other one only on demand. No pack should contain both languages. A dictionary with en and cy keys sitting in the initial bundle is just the double-weight problem with extra steps.

Detect language without guessing

The URL should be the source of truth. If someone lands on /cy/, render Welsh. If they land on /en/ or /, render English unless a stored preference says otherwise. Use a cookie or localStorage for the preference, and make sure the server can read it before the first paint so you do not get a flash of the wrong language.

Do not auto-redirect based on IP address or browser Accept-Language. Geolocation is not language. A visitor in Cardiff may prefer English; a visitor in London may be a Welsh speaker. If the browser hints at cy-GB, show a small, non-modal link — Darllen yn Gymraeg — and let them choose. A banner that hijacks the page is worse than a link that waits.

Pair your URLs with hreflang tags so search engines understand the relationship. Keep the slugs aligned where possible: /en/apply-for-housing and /cy/gwneud-cais-am-dai. If the slugs differ, store the mapping server-side; do not try to derive Welsh URLs by string replacement.

Load the second pack only when asked

The loader can be deliberately small. Think of it as a function with three jobs: check the active language, fetch the requested pack once, and keep the result in memory for the rest of the session.

const packs = new Map();

async function ensurePack(lang) {
  if (lang === document.documentElement.lang) return null;
  if (!packs.has(lang)) {
    const res = await fetch(`/i18n/${lang}.${BUILD_HASH}.json`, {
      credentials: 'same-origin'
    });
    if (!res.ok) throw new Error(`Pack ${lang} failed`);
    packs.set(lang, await res.json());
  }
  return packs.get(lang);
}

Do not prefetch the Welsh pack for every visitor on idle. Prefetch only after intent: a hover or focus on the language switch, or a stored preference that says this person reads Welsh. If someone has never touched the switch and their browser asks for English, leave the Welsh file on the shelf.

Make the switch a real link

The language switch should work without JavaScript. Use an anchor with the correct href, hreflang and lang attributes. Do not use flag icons. Flags are countries, not languages, and the Welsh flag is not a UI control.

<a href="/cy/" hreflang="cy" lang="cy" aria-current="false">Cymraeg</a>
<a href="/en/" hreflang="en" lang="en" aria-current="true">English</a>

Mark the current language with aria-current="true", not just a colour change. When JavaScript intercepts the click for an in-place update, keep the URL in sync with history.pushState. After the swap, set document.documentElement.lang to the new language, update the page title, and move focus to a sensible heading. Announce the change in a polite live region: Language changed to Welsh. If the user is on a slow connection, show a busy state on the switch itself rather than freezing the page.

Handle the mid-session change without drama

A visitor might start in English, read half a page, then switch to Welsh. If the page is mostly static text, swap the strings in place. If there is a half-filled form, do not silently reload and lose it. Either preserve form state before the swap or be honest: show a short confirmation that switching language will reload the page. In-place updates are nicer, but a predictable reload beats a form that empties itself.

If the Welsh pack fails to load, keep the current language and say so in both languages. A failed fetch should not leave the page half-translated. Something like: We could not load Welsh content. English remains. / Ni allwyd llwytho cynnwys Cymraeg. Saesneg sydd ar ôl. Put that message in a live region so screen-reader users hear it too.

Cache the packs, not the person

Give each language pack a content hash and a long cache lifetime. They are immutable until the next build. Do not set Vary: Cookie on every response just because language lives in a cookie; that will destroy your CDN hit rate. Prefer separate URLs for each language so the cache key is the path, and let the cookie handle the root redirect only.

If you must personalise HTML by cookie, keep the cache scope narrow. Language packs themselves should never vary by user. They are the same file for everyone who asks for that language.

What to test before you ship

Test the switch with a keyboard, with JavaScript disabled, and with the Welsh pack deliberately returning a 404. Check that Welsh dates and plurals use the browser's own rules: Intl.DateTimeFormat('cy-GB') should give you 14 Tachwedd 2026, and Intl.PluralRules('cy') handles the extra Welsh plural categories that English does not have. Test zero, one, two and five items in a list.

Then compare the page weight. An English visitor should not download a single Welsh string. A Welsh visitor should get Welsh content without waiting for an English pack first. That is the whole trick: the second language is not bolted on, and it is not paid for by people who do not need it.

A loader that waits is not a second-class language. It is just good manners with a cache header.

All Articles

Related Articles

The Global Header Box Is a Dumping Ground: Building a Script Loader You Can Hand Over

The Global Header Box Is a Dumping Ground: Building a Script Loader You Can Hand Over

The Hash That Breaks on Saturday: Pinning Vendor Scripts Without Breaking Checkout

The Hash That Breaks on Saturday: Pinning Vendor Scripts Without Breaking Checkout

A Script Shelf, Not a Script Free-for-All: Managed Helpers for UK Support Desks

A Script Shelf, Not a Script Free-for-All: Managed Helpers for UK Support Desks