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
i18ninstarter.config.ts(Licensees only) lists the languages.nuxt.config.ts(Licensees only) passes the list to @nuxtjs/i18n with theprefix_except_defaultstrategy.- Copy lives in
i18n/locales/(Licensees only), one JSON file per language. Templates call$t('key'), scripts callt('key')fromuseI18n(). - Nuxt UI's own strings, such as aria-labels and "No data", come from
UI_LOCALESinapp/utils/ui-locales.ts(Licensees only). - On a visitor's first request to
/, the browser's language picks the locale, and thei18n_redirectedcookie remembers it. The server reads the same cookie to choose the language of mail and redirects. - 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' },
],
},
codeis the URL prefix and the file's stem.languageis a BCP 47 tag. It is the hreflang value, the name of the language's child sitemap, and the locale everyIntlcall gets. Pick the tag for the script you write in:srformats dates in Cyrillic,sr-Latn-RSin Latin.nameis what the language switcher shows, written in the language it names.
Then:
- Copy
i18n/locales/en.jsontode.jsonand translate every value. - Add a
deentry to the tables that don't read the config:EMAIL_COPYandUNSUBSCRIBE_COPYinserver/email/copy.ts(Licensees only)BLOG_COLLECTIONSinserver/utils/feed.ts(Licensees only)UI_LOCALESinapp/utils/ui-locales.ts. Nuxt UI ships German, so importdefrom@nuxt/ui/locale. For a language it lacks, write one withdefineLocale, as the file does for Serbian.localesinscripts/blog/blog.config.mjs(Licensees only), if the AI blog workflow should write German too
- 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 asauth.title; a literal"auth.title"key is not. - Write a literal
@as{'@'}. An unescaped@breaks the whole file, and everyt()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
serbianPluralini18n/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/dashboardreturns/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()orswitchLocalePath()goes toULinkor a menu item withlocale: false, asuseLanguageSwitcher(Licensees only) does. Otherwise it is localised twice and the English link points at/sr. - Format dates and numbers with
useLocaleTag(), which returns thelanguagetag rather than the code:new Intl.NumberFormat(useLocaleTag().value). - Query content by a path rebuilt from the route's
slugparam, never byroute.path, which carries the prefix. GiveuseAsyncDataa 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:
- Add the reason to
REFUSAL_REASONSinshared/refusals/reasons.ts(Licensees only). - 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 }, }) - Map it to a key in
REFUSAL_COPY_KEYSinapp/utils/refusal-copy.ts(Licensees only), for example'report-too-large': 'refusals.reportTooLarge'. - Add
refusals.reportTooLargeto 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.
Build a localised link on the server
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?)prefixespathwith the visitor's locale, or withlocale. 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
| Key | Where | Default | What it does |
|---|---|---|---|
i18n.defaultLocale | starter.config.ts | 'en' | Served at /. Fills missing keys and untranslated documents |
i18n.locales | starter.config.ts | en (en-US), sr (sr-Latn-RS) | The languages. One entry turns prefixes, detection and the switcher off |
i18n.locales[].file | starter.config.ts | en.json, sr.json | The file under i18n/locales/ |
locales | scripts/blog/blog.config.mjs | en, sr | Languages 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()innuxt.config.ts, or/sr/dashboardmisses thessr: falsethat/dashboardhas. - 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.titleandblog.descriptionare printed by the RSS feed without vue-i18n. Keep them plain text: a{,},@or|fails the build (Blog).nuxtSiteConfig.nameis@:brand.name, the one linked message. It names the site in the structured data on every page, so rename the product inbrand.nameand leave the link alone.- The browser's language is only consulted at
/. A visitor who opens/pricingdirectly 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.