Skip to content

Content and look

Languages

The app ships in English and Serbian (Latin script). The default language is served at /, and every other language gets a path prefix: /sr, /sr/blog, /sr/dashboard. You work here when you add or remove a language, or write copy and code that has to follow the reader's language.

How it works

  1. i18n in starter.config.ts(Licensees only) lists the languages. nuxt.config.ts(Licensees only) passes the list to @nuxtjs/i18n with the prefix_except_default strategy.
  2. Copy lives in i18n/locales/(Licensees only), one JSON file per language. Templates call $t('key'), scripts call t('key') from useI18n().
  3. Nuxt UI's own strings, such as aria-labels and "No data", come from UI_LOCALES in app/utils/ui-locales.ts(Licensees only).
  4. On a visitor's first request to /, the browser's language picks the locale, and the i18n_redirected cookie remembers it. The server reads the same cookie to choose the language of mail and redirects.
  5. A missing key renders the default language's string. An untranslated blog post or legal page is served from the default language's content at the prefixed URL.

Add a language

Add the entry to starter.config.ts:

i18n: {
  defaultLocale: 'en',
  locales: [
    { code: 'en', language: 'en-US', name: 'English', file: 'en.json' },
    { code: 'sr', language: 'sr-Latn-RS', name: 'Srpski', file: 'sr.json' },
    { code: 'de', language: 'de-DE', name: 'Deutsch', file: 'de.json' },
  ],
},
  • code is the URL prefix and the file's stem.
  • language is a BCP 47 tag. It is the hreflang value, the name of the language's child sitemap, and the locale every Intl call gets. Pick the tag for the script you write in: sr formats dates in Cyrillic, sr-Latn-RS in Latin.
  • name is what the language switcher shows, written in the language it names.

Then:

  1. Copy i18n/locales/en.json to de.json and translate every value.
  2. Add a de entry to the tables that don't read the config:
  3. Translate the legal pages into content/de/legal/.

The build refuses a declared language whose JSON file is missing, and the tests fail for each of the first four tables until it has the new code. The blog's locales is not checked. Route rules, sitemap exclusions, analytics path folding, the switcher and the content collections all read i18n.locales, so they need no edit.

Remove a language

Delete its entry in i18n.locales, its i18n/locales/<code>.json, its content/<code>/, and its entries in the tables above. Tests that exercise that language by name go too: for Serbian, test/nuxt/serbian-locale.test.ts and the sr cases in test/unit/unsubscribe-route.test.ts and test/unit/blog-feed.test.ts.

With one language left, the app switches itself to no_prefix: no browser language redirect, no hreflang alternates, one flat /sitemap.xml, and a switcher that renders nothing. vitest.config.ts skips the four component suites that need a second language. $t() keeps working, so adding a language back is the config entry above.

A later pnpm template:update that changes a file you deleted, usually sr.json, stops on a modify/delete conflict. git rm the file again and continue (Template).

Write copy

  • Keys are nested objects. "auth": { "title": … } is reachable as auth.title; a literal "auth.title" key is not.
  • Write a literal @ as {'@'}. An unescaped @ breaks the whole file, and every t() in the app then renders its key path.
  • Write plurals with as many branches as the language has. English has two; Serbian has three, picked by serbianPlural in i18n/i18n.config.ts(Licensees only):
    "locked": "Pokušajte ponovo za {minutes} minut. | Pokušajte ponovo za {minutes} minuta. | Pokušajte ponovo za {minutes} minuta."
    
  • An empty string is a value. Fallback doesn't replace it, and the tests refuse it.

test/unit/i18n-locales.test.ts(Licensees only) holds every file to en.json: the same keys, the same interpolations, the right number of plural branches, no unescaped @ and nothing blank. It also checks that every declared language has a file and every file is declared.

Write code that follows the language

Anything holding a translated string is a computed or a getter. t() resolves once, when it is called, so a module-scope schema or a plain string in useSeoMeta stays in the language the page first loaded in:

const { t } = useI18n()

const schema = computed(() => z.object({ email: emailSchema(t) }))

useSeoMeta({
  title: () => t('seo.dashboard.settings.activity.title'),
})
  • Build internal links with useLocalePath(). It is idempotent, so passing /sr/dashboard returns /sr/dashboard. It returns an empty string for a route that doesn't exist, so a link to a page you may delete needs a fallback: localePath('/pricing') || '/pricing'.
  • A path already produced by localePath() or switchLocalePath() goes to ULink or a menu item with locale: false, as useLanguageSwitcher(Licensees only) does. Otherwise it is localised twice and the English link points at /sr.
  • Format dates and numbers with useLocaleTag(), which returns the language tag rather than the code: new Intl.NumberFormat(useLocaleTag().value).
  • Query content by a path rebuilt from the route's slug param, never by route.path, which carries the prefix. Give useAsyncData a getter key that includes the locale, because switching language reuses the page component.

Word a server refusal

A route's statusMessage stays English: it is what logs and API clients read. Every refusal a person can see also carries a data.reason, and the browser words it from the locale files. Adding one takes four edits:

  1. Add the reason to REFUSAL_REASONS in shared/refusals/reasons.ts(Licensees only).
  2. Throw it:
    import type { RefusalReason } from '#shared/refusals/reasons'
    
    throw createError({
      statusCode: 413,
      statusMessage: 'The report is too large to export.',
      data: { reason: 'report-too-large' satisfies RefusalReason },
    })
    
  3. Map it to a key in REFUSAL_COPY_KEYS in app/utils/refusal-copy.ts(Licensees only), for example 'report-too-large': 'refusals.reportTooLarge'.
  4. Add refusals.reportTooLarge to every locale file.

useAuthErrorToast() then shows the translated copy under your own title:

const addAuthError = useAuthErrorToast()

addAuthError(error, { title: t('dashboard.settings.privacy.saveFailed') })

A description you pass wins over the reason's copy. A refusal whose reason the catalogue doesn't know is worded by its status code, never with the server's English. test/unit/refusal-copy.test.ts(Licensees only) fails on a reason without copy, on copy no reason uses, and on a declared reason nothing on the server sends.

The server has no useLocalePath(). server/utils/i18n.ts(Licensees only) stands in for it:

  • requestLocale(event) is the cookie's locale when it names a configured language, and the default otherwise.
  • localeRedirect(event, path, locale?) prefixes path with the visitor's locale, or with locale. The default language stays unprefixed, and a locale segment already on the path is replaced rather than doubled.
  • localeCodes(event) lists the configured codes. Check a path segment against it before using it as a locale.

This is how the password reset mail builds its link:

return `${base}${localeRedirect(event, '/reset-password', locale)}?token=${token}`

Configuration

KeyWhereDefaultWhat it does
i18n.defaultLocalestarter.config.ts'en'Served at /. Fills missing keys and untranslated documents
i18n.localesstarter.config.tsen (en-US), sr (sr-Latn-RS)The languages. One entry turns prefixes, detection and the switcher off
i18n.locales[].filestarter.config.tsen.json, sr.jsonThe file under i18n/locales/
localesscripts/blog/blog.config.mjsen, srLanguages the AI blog workflow writes and translates

There are no environment variables.

Good to know

  • Nitro matches route rules against the raw path, which knows nothing of the prefixes. Wrap a new rule in perLocale() in nuxt.config.ts, or /sr/dashboard misses the ssr: false that /dashboard has.
  • An untranslated legal page is served under the other language as that language's terms. To answer 404 instead, remove the fallback query in app/pages/legal/[...slug].vue(Licensees only).
  • brand.name, blog.title and blog.description are printed by the RSS feed without vue-i18n. Keep them plain text: a {, }, @ or | fails the build (Blog).
  • nuxtSiteConfig.name is @:brand.name, the one linked message. It names the site in the structured data on every page, so rename the product in brand.name and leave the link alone.
  • The browser's language is only consulted at /. A visitor who opens /pricing directly gets English, whatever their browser prefers, because the URL names the language.

A link with a lock opens the kit’s private repository, which every licensee can read.