All posts

All posts

Serving the site in French at its own paths

Why French lives at /fr while English keeps the bare paths, and how one language choice follows a visitor from the site into the application.

The public site is prerendered and indexed, so its language belongs in the URL, where a search engine and a shared link can both see it. English keeps the unprefixed paths, and French lives under /fr: /services and /fr/services. The application, served from its own origin, keeps its language out of the URL altogether. The two halves are joined by one cookie.

Why /fr and not /en#

The symmetric scheme, with /en/services beside /fr/services, would have moved every existing URL to buy a symmetry nobody reads. It was worse at the root. The site is static, so / would have had to become a redirect page. That page would be the most-linked URL on the site, a weak signal to a crawler, and indistinguishable from the empty mount point the build already exists to refuse. / also carries the script that sends a signed-in visitor to the application. So English stays where it was, and the asymmetry is deliberate.

The application went the other way. Its pages are not indexed and are deep-linked between colleagues, so a prefix would double every route and buy nothing. There the language is a stored preference, sent with every request as Accept-Language.

One choice across two origins#

The site and the application are two origins, and browser storage is per origin, which is also why a theme choice made on one cannot be seen on the other. A cookie scoped to the registrable domain can be shared, so that is where the language choice lives.

The order the sources are read in matters. A choice in the cookie wins, because it is the only one a person actually made. The browser's preferred languages come next, since they are only what someone's software was configured to prefer. The pre-paint script on the site and the API both resolve in that order. In the application, a language saved on the account is adopted only when there is no cookie, so signing in never overrides a choice made on the device. That adopted language is handed back at sign-out, so the next person to sign in on the same machine doesn't inherit the previous account's language, and with it the language of the mail their requests send.

Writing that cookie turned out to need more care than reading it. A browser silently discards a cookie whose Domain the current host does not end in, with no error and nothing to see afterwards. On a misconfigured origin, the whole symptom would be a language choice that didn't survive a reload. Every write is therefore read back, and one that didn't stick falls back to a host-only cookie. That cookie cannot reach the other origin, but it holds on the page in front of the visitor. Reading document.cookie can also throw on an opaque origin, and the application reads the cookie while its modules load, so an unguarded read stopped the page from ever mounting.

Two redirects before the first paint#

A visitor arriving at the site root with a French browser is sent to /fr before anything paints, rather than shown English and corrected a moment later. The same root already had one pre-paint redirect, for signed-in visitors, and the two could not be ordered by position alone: location.replace does not stop the script that calls it. Without a guard of its own, the language snippet would run after the signed-in redirect had fired and send a signed-in French speaker to /fr, where the signed-in redirect never fires. They would be stranded on the marketing site for good.

The other trap was a loop. The snippet reads the cookie and must never write it, and the language switcher writes the cookie and must never read it. Without the write, a French browser that clicked English would land on /, be sent straight back to /fr, and stay there.

One module owns the path arithmetic#

Every route exists once per language, generated by crossing the route table with the list of languages, so none is listed twice. The pages the prerender writes come from that same crossing, so the files the build produces and the routes the router serves cannot disagree.

A single module converts paths in both directions, and the inverse has to be exact. The prefix test is anchored on the separator, so /france stays an English path. That module returns the language-neutral path that the route table, the alternates, the prerender and the switcher all key on. One early fallback read the raw route path instead, which on a French page already has its prefix. The head then declared the French alternate of /fr/services to be /fr/fr/services, and the switcher offered the same doubled link.

Hreflang, where a mistake is invisible#

Every page carries lang, a canonical URL, and a reciprocal set of alternates including x-default. A broken cluster looks exactly like a working one in a browser. The canonical is built from the same call as the alternates, so the page's alternate for its own language equals its canonical by construction. When the two were derived separately, they agreed only by coincidence.

The build reads the emitted files rather than the configuration. A wrong lang, a French page carrying English copy, a broken alternate, a missing language chunk, or a stripped preload of the message catalogue each fail it. Those checks report a list of failures and don't throw. An unguarded file read had been throwing out of the whole run, discarding every failure already collected and leaving an ENOENT stack trace where the gate had just worked out what was wrong.

No internationalisation library on the site#

The site needs a lookup over a constant object, with placeholders, and nothing else. It got a small translator of about forty lines. vue-i18n stays on the site's list of forbidden packages, which the build enforces against the emitted bytes. The kit's password field imports it, so the list is what catches it arriving through a shared component. The translator is provided per app instance and not held in a module, because the prerender renders every page in both languages in one process, and a shared one would leak one page's language into the next. Placeholder substitution only reads keys the message object owns itself. Otherwise a message containing {toString} would have substituted a function from the prototype into the page.

What only French could break#

Several bugs could only surface in the second language. The server renderer escapes an apostrophe to ', and the build check compared headings raw. Every English heading was plain words, so the check passed until the first French heading with an apostrophe arrived. The header logo linked to /, a route the French table doesn't contain. The router warned once per prerendered French page, which read as build noise, and a visitor clicking the logo left French. Loading the catalogue added a round trip before hydration, which a keyboard test caught by pressing Tab too early. The prerender now preloads each language's catalogue. And the header and footer both drew a navigation landmark named Language, two entries in a screen reader's list that nobody could tell apart.

The API, as it turned out, had been ready for a while. It carried translated catalogues and negotiated Accept-Language, and mail went out per locale. None of that was reachable, because the frontend never sent the header: it agreed with the application's hard-coded English by coincidence. The API now reads quality values the way they are defined, so fr;q=0 is a refusal and not a request. It also stores a language on the account only when a request actually named one, because a script or a bare curl names none, and recording English for it would have hidden the real preference for good. Its Dutch catalogue went: nobody was translating it, and carrying a third language through every parity check cost more than deleting it.

Five weeks later, both language switchers became the same control: a chip showing the language in force, with its flag, that opens a native popover listing each language. It needs no script, so on the site it works on the prerendered page before hydration, for about 2.4 KB on the wire.