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.