Internationalization (i18n)

Multi-language support built on @nuxtjs/i18n, with built-in Simplified Chinese, Traditional Chinese, and English. Translation files use a flat structure that is easy to read and AI-friendly.

Enable i18n

// nuxt.config.ts
swiftConfig: {
  i18n: {
    enabled: true,
    defaultLocale: 'zh-CN',
    locales: ['zh-CN', 'zh-TW', 'en'],
  }
}

swiftConfig.i18n.locales does exactly one thing: it maps language codes into @nuxtjs/i18n's { code, language } shape so route prefixes (/en/, /zh-TW/) get generated. It does not register translation files — there is no file field in it.

Translation files are wired up by the top-level i18n block in nuxt.config.ts (sw config set framework.i18n.enabled true uncomments this block in the starter for you):

// nuxt.config.ts — top-level i18n, a sibling of swiftConfig
i18n: {
  langDir: 'locales',                                    // relative to the i18n/ directory
  locales: [
    { code: 'zh-CN', language: 'zh-CN', name: '简体中文', file: 'zh-CN.json' },
    { code: 'zh-TW', language: 'zh-TW', name: '繁體中文', file: 'zh-TW.json' },
    { code: 'en',    language: 'en',    name: 'English',  file: 'en.json' },
  ],
}

The top-level i18n.locales overrides the framework default, so keep the language codes in both places in sync. Setting only swiftConfig.i18n.locales and skipping the top-level file entries yields working route prefixes but no translations at all — $t() just echoes the key back. Strategy, browser detection, bundling and the rest come from framework defaults; you don't need to restate them.

Built-in framework copy and localization

Framework components ship their own copy too — the paginator's "N items", the feed's "Skip in Ns", the modal's "Close / Cancel / Confirm", plus ~33 aria-labels. These follow your active language automatically, with zero changes on your side.

  • With i18n enabled: follows the user's currently selected language (not the build-time defaultLocale) — flip SwLangSwitch and framework copy changes right along with it
  • With i18n disabled: always Chinese, zero overhead
  • zh-CN / zh-TW / en are built in; any other language (say ja) falls back to Chinese rather than rendering blanks
  • Language codes are normalized by primary subtag: zh-Hant / zh-HK / zh-MO map to Traditional, en-US / en-GB map to English
<!-- Nothing to do on your side: switch to en and the paginator is already English -->
<SwPagination v-model:page="page" :total="total" show-total />
<!-- zh-CN → 「共 100 条」/ zh-TW → 「共 100 筆」/ en → "100 items" -->

Components that already expose a slot / prop escape hatch are unaffected — SwModal's cancelText / confirmText, SwGallery's #empty / #finished and friends still win when you pass them; the built-in dictionary only kicks in when you don't.

Translation File Structure

Translation files live in i18n/locales/ using a flat JSON format:

// i18n/locales/zh-CN.json
{
  "首页": "首页",
  "快速上手": "快速上手",
  "立即开始": "立即开始",
  "首页SEO描述": "Swift Web 框架的官方文档站,涵盖组件库和开发规范"
}
// i18n/locales/en.json
{
  "首页": "Home",
  "快速上手": "Quick Start",
  "立即开始": "Get Started",
  "首页SEO描述": "Official docs for Swift Web framework, covering components and standards"
}

Usage in Pages

<script setup lang="ts">
const { t } = useI18n()

useSeo({
  title: () => t('首页'),
  description: () => t('首页SEO描述'),
})
</script>

<template>
  <h1>{{ t('快速上手') }}</h1>
  <button>{{ t('立即开始') }}</button>
</template>

Language Switching

<template>
  <!-- Built-in switcher: renders only when i18n is enabled with locales ≥ 2 (self-gating,
       zero footprint otherwise); customizable via tokens (--sw-lang-switch-*),
       named icon slots, or the default scoped slot -->
  <SwLangSwitch />
</template>

Hand-roll only when you need a fully custom UI (note: use <option :selected> instead of <select :value> — the latter is invalid HTML and picks the wrong option on SSR-rendered loads):

<script setup lang="ts">
const { locale, setLocale, locales } = useI18n()
</script>

<template>
  <select @change="setLocale(($event.target as HTMLSelectElement).value)">
    <option v-for="loc in locales" :key="loc.code" :value="loc.code" :selected="loc.code === locale">
      {{ loc.name }}
    </option>
  </select>
</template>

Translation Key Conventions

  • Short strings (≤50 characters): use the Chinese original as the key, e.g. t('立即开始')
  • Long strings (>50 characters): use a short Chinese description as the key with the full text in the JSON value, e.g. t('首页SEO描述')
  • Do not use long sentences as keys — it makes translation files hard to read

URL Localization

@nuxtjs/i18n uses a path-prefix strategy by default:

  • zh-CN (default locale): example.com/ (no prefix)
  • zh-TW: example.com/zh-TW/
  • en: example.com/en/

The canonical tag is injected by useSeo(); for multilingual sites (locales ≥ 2, prefix strategy) the sitemap automatically splits per locale and emits hreflang cross-links (zero configuration, see SEO).

Browser Language Detection Does Not Work on Cacheable Pages

@nuxtjs/i18n's detectBrowserLanguage (redirecting visitors to their locale prefix based on Accept-Language / cookie) is structurally inert on ISR / cacheable pages: nitro's cache wrapper only passes the cache.varies whitelist of request headers into the render (host / x-forwarded-proto), so Accept-Language and Cookie never reach the detection logic. This is not a configuration problem, and adding them to varies is not a way out — varies feeds the cache key, and splitting by cookie value drives the hit rate to zero.

Actual behavior and trade-offs:

  • Landing directly on a cacheable page (including the home page) never triggers a locale redirect; every visitor gets the default-locale cached copy. This is deterministic — which also means a detection redirect can never be broadcast from the cache, so there is no correctness risk.
  • Visitors switch languages via SwLangSwitch; the path prefix (/en/...) is the durable locale carrier.
  • The framework strips any Set-Cookie written during the render of a cacheable page (including i18n's detection-marker cookie): a cacheable response carrying Set-Cookie would be baked into the shared cache copy and replayed, and makes nginx / CDNs without proxy_ignore_headers refuse to cache the page at all (see the deployment guide troubleshooting table). In dev mode the strip logs a console warning naming the stripped cookie; write per-user cookies on the client, or make the route non-cacheable.

html lang Follows the Active Locale

On multilingual sites (locales ≥ 2), a built-in runtime plugin takes over <html lang> with the active locale (using the locale's language field, falling back to code):

  • Visiting /en/xxx → <html lang="en">, correct from the first SSR frame
  • Switching languages in-site (SwLangSwitch) → lang updates reactively
  • Single-locale sites / i18n disabled keep the build-time static value (i18n.defaultLocale) — zero behavior change

Zero configuration for appliers. Do not double-write useHead({ htmlAttrs: { lang } }) on the business side — htmlAttrs ownership belongs to the framework, and double-writing causes conflicting overrides.