Skip to content

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

SurfaceWhat it does
apps/web/nuxt.config.tsRegisters @nuxtjs/i18n with strategy: 'no_prefix', default en, lazy.
apps/web/i18n/locales/*.jsonPer-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.tsWatches 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 cookieFast-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

  1. Hard reload, signed-in user — SSR reads the tk_locale cookie first (detectBrowserLanguage.useCookie: true) and renders directly in that language. Client hydrates with the same locale → no flash, no hydration mismatch.
  2. Anonymous visitor — same path: SSR reads the cookie if present, otherwise falls back to Accept-Language, otherwise to defaultLocale.
  3. Session landsi18n-user.client.ts watches useUserSession(). When user.language differs from the cookie-driven locale (e.g. the user changed it on another device), the plugin overrides via setLocale(), which also rewrites the cookie.
  4. User picks a language in /settings#appearance — optimistic setLocale() 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/status poll.

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():

vue
<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:

json
{ "memberSince": "Member since {date}" }
vue
{{ $t('me.memberSince', { date: '2026-05-07' }) }}

Plurals

Pipe-separated forms cover singular / plural:

json
{ "torrents": "{n} torrent | {n} torrents" }
vue
{{ $t('home.stats.torrents', { n: count }) }}

For two-argument plural lookups (count + named placeholders together) pass the count as the second positional argument:

json
{ "activeSeeds": "{n} active seed · {invites} invite | {n} active seeds · {invites} invites" }
vue
{{ $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, filters

When 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.

  1. Drop a file at apps/web/i18n/locales/<code>.json. Copy en.json and translate every value. Missing keys fall back to English at runtime — no risk of empty strings.
  2. Register it in nuxt.config.ts:
    ts
    i18n: {
      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' },
      ],
    }
  3. Restart nuxt dev so the module re-reads nuxt.config.ts and bundles the new file.
  4. Widen the back-end Zod enum at apps/api/routes/api/me/index.patch.ts so language: z.enum([...]) accepts the new code. Without this the PATCH would reject the picker's request with a 400.
  5. Add a card to the languages array in apps/web/app/pages/settings.vue:
    ts
    const 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

  1. Pick a namespace (nav.foo, me.bar, …).
  2. Replace the literal in the template with $t('namespace.key') (or :placeholder="$t(...)" / :title="$t(...)" for attributes).
  3. 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.

A cookie-only approach was the first cut and it worked, but had three gaps:

  1. 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.
  2. Cookie flush — clearing site data flushed the language back to the browser default, which was confusing for users whose Accept-Language didn't match the language they actually wanted.
  3. 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 correct href. Cookie-based switching keeps every link single-origin.

Released under the MIT License.