Internationalization (i18n)
The web frontend ships with @nuxtjs/i18n wired up for two locales out of the box — English (default) and French — plus a navbar language picker that persists the active language to a cookie.
Adding a third language is a config + JSON change with no template edits; adding a new translatable string is a one-line $t() call in the template and an entry in each locale file.
Architecture
| Surface | What it does |
|---|---|
apps/web/nuxt.config.ts | Registers @nuxtjs/i18n with strategy: 'no_prefix', default en, lazy. |
apps/web/i18n/locales/*.json | Per-locale translation bundles, namespaced by feature (nav, auth, …). |
apps/web/app/pages/settings.vue (Appearance) | Account-bound language picker. Saves to users.language via PATCH /api/me. |
apps/web/app/plugins/i18n-user.client.ts | Watches useUserSession() and re-applies the saved locale via setLocale(). |
users.language (Postgres) | Source of truth — the locale follows the user across devices and survives a cookie flush. |
tk_locale cookie | Fast-path cache. SSR reads it on hard reload so the page renders in the right language without paying for an /api/auth/status round-trip first. Auto-written by setLocale() (see detectBrowserLanguage.useCookie: true). |
URLs stay clean (/torrents, never /fr/torrents) — the choice is purely account state. A private tracker isn't crawled for SEO, so we don't pay for prefix-based locale routing.
Where the locale comes from, in order
- Hard reload, signed-in user — SSR reads the
tk_localecookie first (detectBrowserLanguage.useCookie: true) and renders directly in that language. Client hydrates with the same locale → no flash, no hydration mismatch. - Anonymous visitor — same path: SSR reads the cookie if present, otherwise falls back to
Accept-Language, otherwise todefaultLocale. - Session lands —
i18n-user.client.tswatchesuseUserSession(). Whenuser.languagediffers from the cookie-driven locale (e.g. the user changed it on another device), the plugin overrides viasetLocale(), which also rewrites the cookie. - User picks a language in
/settings#appearance— optimisticsetLocale()paints the new language →PATCH /api/me { language }persists it to the DB →refreshSession()ensures every other tab picks it up on the next/api/auth/statuspoll.
The only place to change the language is /settings (Appearance section). A logged-in user's UI language is bound to their account by design — the goal is "follow me across devices," not "let any browser tab override the account preference."
Plugin order matters
apps/web/app/plugins/i18n-user.client.ts is registered with enforce: 'post' so it always runs after the @nuxtjs/i18n module's own client plugin. Without that gate, my plugin sorts alphabetically before i18n.plugin and would call useI18n() before the message compiler is mounted — surfacing as [nuxt] error caught during app initialization SyntaxError: 26 (vue-i18n's INVALID_ARGUMENT runtime error) on cold boot.
How a string gets translated
The pattern is the same in every component — call $t('key.path') from the template, or t('key.path') from the <script setup> after destructuring useI18n():
<template>
<span>{{ $t('nav.dashboard') }}</span>
<input :placeholder="$t('home.searchPlaceholder')" />
<p>{{ $t('me.memberSince', { date: memberSince }) }}</p>
<p>{{ $t('me.stats.activeSeeds', n, { n, invites }) }}</p>
</template>
<script setup lang="ts">
const { t } = useI18n();
const message = computed(() => t('common.loading'));
</script>Each call has a matching entry in every locale file. If a key is missing from fr.json the runtime falls back to en.json (the fallbackLocale config) — the UI never shows a raw key.
Interpolation
Plain placeholders use the named-pattern syntax:
{ "memberSince": "Member since {date}" }{{ $t('me.memberSince', { date: '2026-05-07' }) }}Plurals
Pipe-separated forms cover singular / plural:
{ "torrents": "{n} torrent | {n} torrents" }{{ $t('home.stats.torrents', { n: count }) }}For two-argument plural lookups (count + named placeholders together) pass the count as the second positional argument:
{ "activeSeeds": "{n} active seed · {invites} invite | {n} active seeds · {invites} invites" }{{ $t('me.stats.activeSeeds', count, { n: count, invites: 3 }) }}Locale files — namespacing
Keys are grouped by surface so search/replace doesn't hit unrelated copy:
common — shared verbs (Save, Cancel, Delete, …)
nav — navbar / drawer entries (Dashboard, Torrents, Sign out, …)
home — hero, search bar, recent activity
auth.login — login page (placeholders, errors, banners)
auth.register — register page
me — user profile (KPI strip, credentials, activity)
settings — Identity / Privacy / Security tabs
torrents — listing page columns, filtersWhen you migrate a new page, add its keys under a new top-level namespace (forum, admin, mod, …) instead of stuffing them into common. That way translators see the page boundary in the JSON.
Adding a new language
Five steps — the first three are the actual translation, the last two are about exposing it to the picker and the back-end validator.
- Drop a file at
apps/web/i18n/locales/<code>.json. Copyen.jsonand translate every value. Missing keys fall back to English at runtime — no risk of empty strings. - Register it in
nuxt.config.ts:tsi18n: { locales: [ { code: 'en', language: 'en-US', name: 'English', file: 'en.json' }, { code: 'fr', language: 'fr-FR', name: 'Français', file: 'fr.json' }, { code: 'de', language: 'de-DE', name: 'Deutsch', file: 'de.json' }, ], } - Restart
nuxt devso the module re-readsnuxt.config.tsand bundles the new file. - Widen the back-end Zod enum at
apps/api/routes/api/me/index.patch.tssolanguage: z.enum([...])accepts the new code. Without this the PATCH would reject the picker's request with a 400. - Add a card to the
languagesarray inapps/web/app/pages/settings.vue:tsconst languages: LanguageOption[] = [ { value: 'en', native: 'English', region: 'English (US)' }, { value: 'fr', native: 'Français', region: 'French (France)' }, { value: 'de', native: 'Deutsch', region: 'German (Germany)' }, ];
No DB migration is needed — the column is free-form text so historical rows referencing a removed locale stay readable; the runtime falls back to defaultLocale at boot when the saved code isn't in the bundled set.
Adding a new translatable string
- Pick a namespace (
nav.foo,me.bar, …). - Replace the literal in the template with
$t('namespace.key')(or:placeholder="$t(...)"/:title="$t(...)"for attributes). - Add the entry to every locale file under the matching namespace.
The dev server hot-reloads on JSON changes; refresh the page to see the new copy.
Locale persistence
The active locale is persisted on the user's account (users.language column). The cookie (tk_locale) is kept around for the pre-auth case only — anonymous visitors landing on /auth/login still get a sensible default from Accept-Language.
Why account-bound and not just a cookie
A cookie-only approach was the first cut and it worked, but had three gaps:
- Device drift — picking French on a laptop didn't help when the same user logged in from their phone. They had to re-pick on every device.
- Cookie flush — clearing site data flushed the language back to the browser default, which was confusing for users whose
Accept-Languagedidn't match the language they actually wanted. - Cross-tab consistency — switching language in one tab updated the cookie but didn't notify other open tabs until the next navigation.
Persisting on the row + a watcher on useUserSession() fixes all three in one go: every tab reads the same user.language, every device sees the same value at login, and setLocale() is called automatically from the plugin without the picker having to broadcast anything.
Migration / new column
The users.language column was added in May 2026 with a default of 'en' and NOT NULL semantics, so existing rows backfill silently. Adding a new locale doesn't require a migration — the column is free-form text, validated by Zod (z.enum(['en', 'fr'])) at the PATCH /api/me boundary. Add a value to that enum, drop a JSON file under i18n/locales/, and update the picker's languages array.
Coverage status
The first wave covers the highest-traffic surfaces — navbar, home, login, the /me hero / KPI strip / section titles. The deeper pages (/admin/*, /mod/*, /forum/*, the rest of /me, settings tabs, upload form, etc.) still carry hardcoded English; migrating them follows exactly the pattern above and is safe to land incrementally.
When you migrate a page, add its keys to both en.json and fr.json in the same PR. The build doesn't fail on missing keys but the fallback to English in a French session is jarring — keep the two files in lockstep.
Why not URL-prefixed locales?
strategy: 'prefix_except_default' would give us /torrents (English) and /fr/torrents (French). It's the right call for SEO-driven sites. A private tracker is the wrong use case:
- The whole site is auth-walled — search engines never see it.
- Operators link to specific torrent hashes; doubling the URL space creates dead links across language switches.
<NuxtLink to="/torrents/abc">would have to know the active locale to emit a correcthref. Cookie-based switching keeps every link single-origin.