UI localization — adding a language
How Owlat's UI translations work: where the four message catalogs live, how to add a locale, the rules for writing translatable strings, and how a visitor's language is detected, switched and declared.
UI localization — adding a language
Owlat has two independent translation systems. Do not confuse them:
| System | What it translates | Where it lives |
|---|---|---|
| Email content translations | The campaigns, templates and blocks you send | Per-workspace, stored in Convex — see Translations in the product guide |
| UI localization (this page) | The Owlat interface your team and your recipients read | One catalog per app plus one for the shared UI layer, shipped with the code — see Where things live |
The UI layer is @nuxtjs/i18n. en is the default locale and the fallback for every other one, so an untranslated key renders its English text rather than a raw key path.
Where things live
There are four catalogs, one per Nuxt app plus one for the layer they all extend. Each is a directory of its own, and each app registers its own locale list:
apps/web/i18n/locales/{en,de}.json # the product: dashboard, Postbox, admin, auth, recipient pages
apps/marketing/i18n/locales/{en,de}.json # the public site (owlat.app)
apps/docs/i18n/locales/{en,de}.json # this site's chrome — its PAGES live in content/{en,de}/
packages/ui/i18n/locales/{en,de}.json # the shared UI layer (`ui.*`), inherited by all three
An individual app looks like this:
apps/web/
├── nuxt.config.ts # the `i18n` block: locale list, strategy, detection
└── i18n/
├── i18n.config.ts # vue-i18n runtime options (fallbackLocale, warnings)
└── locales/
├── en.json # source of truth
└── de.json # a translation
Locale files are lazy-loaded: a visitor downloads only the catalog for the locale they are actually shown.
In the app, routes are not prefixed (strategy: 'no_prefix'). Every Owlat URL there is either a dashboard route or a token link that was already printed inside a sent email (/unsubscribe?token=…), so a /de/ segment would break live links. The marketing site and this documentation site prefix instead (prefix_except_default); the reasoning is in This documentation site below.
The shared UI layer
packages/ui is a Nuxt layer, so its copy (ui.* — modal close buttons, alert headings, "Try again") cannot live in any one app's catalog. It contributes its own i18n.locales from packages/ui/nuxt.config.ts, and @nuxtjs/i18n merges the per-locale file lists with the layer's file first and the app's last — so an app can override any ui.* message by redeclaring the key in its own catalog.
The layer does not list @nuxtjs/i18n in its modules: the module is installed by the consuming apps, and a layer may not impose it on an app that has not adopted i18n. That is why layer components translate through useUiI18n() (packages/ui/composables/useUiI18n.ts) rather than useI18n() directly — it falls back to the bundled English catalog when there is no vue-i18n instance, or when the instance's messages carry no ui.* (which is what an app's own component test looks like). The cost of that fallback: a broken layer hookup reads as untranslated English, not as visible key paths, so verify the wiring on the German copy — the modal close button must say "Dialog schließen".
The marketing site
apps/marketing follows the same rules and registers @nuxtjs/i18n first in modules, so the SEO modules (sitemap, robots, og-image) see the locale list while they set themselves up and emit per-locale entries. i18n.baseUrl is the canonical site URL, which is what makes the hreflang alternates absolute. Browser detection is deliberately off there: the language is chosen through the header switcher, search engines are pointed at the right variant by the alternates, and auto-redirecting on Accept-Language would bounce a German-configured browser away from a link that was explicitly shared as the English page.
This documentation site
The docs (apps/docs) follow the same rules with two additions, because they translate pages, not just chrome.
URLs are prefixed (strategy: 'prefix_except_default'). English keeps the URLs it has always published — /guide/quick-start — and German lives at /de/guide/quick-start. Nothing here was ever printed inside an email, so a locale segment is free, and it is what lets a German page be shared, indexed and given its own hreflang alternate.
The markdown tree is mirrored per locale, one directory and one content collection each:
apps/docs/
├── content.config.ts # one page collection per locale
├── i18n/
│ ├── i18n.config.ts
│ └── locales/{en,de}.json
└── content/
├── en/1.guide/2.quick-start.md # → /guide/quick-start
└── de/1.guide/2.quick-start.md # → /de/guide/quick-start
Each collection strips its own locale directory (source: { include: 'de/**/*.md', prefix: '' }), so every collection publishes the same locale-free paths. A query never names a locale in the path — it picks the collection instead:
const { locale } = useI18n();
const page = await queryDocsPage(docsCollection(locale.value), '/guide/quick-start');
docsCollection, contentPath (which strips the /de a router path carries) and the query helpers all live in app/composables/useDocsContent.ts. Use them rather than queryCollection directly — they are where the fallback lives.
A translation may lag; a page may not
The German tree is complete today, and it stays shippable while it is not: a missing page falls back to its English source instead of 404ing, which is what lets a new English page land without blocking on its translation. The rule holds everywhere a page is read:
| Surface | Behaviour |
|---|---|
| Page body | The locale's page, else the English page — at the German URL, inside German chrome |
| Search | The locale's pages, topped up with English pages that have no translation yet |
| Prev / next | Reading order always from English (the only complete tree); titles localized where they exist |
| Sidebar, breadcrumb, in-page links | Locale-free paths run through localePath, so a reader stays in /de |
The one place this does not apply is a wrong URL: /de/nonsense is a genuine 404 and renders app/error.vue.
Sidebar labels
app/utils/sidebarConfig.ts stays the single source of truth for the nav, English labels included. Translations are looked up under a key derived from the entry — sidebar.groups.<section>-<label>, sidebar.items.<path>, both slugified — and fall back to the config's label, so adding a page without touching the catalog renders English rather than a raw key path. __tests__/localeCatalogs.test.ts fails on that drift, on an orphaned key, and on two entries that would collapse onto one key.
Adding a locale
- Copy the source catalog.
cp apps/web/i18n/locales/en.json apps/web/i18n/locales/fr.json - Register it in
apps/web/nuxt.config.ts:locales: [ { code: 'en', language: 'en-US', name: 'English', file: 'en.json' }, { code: 'de', language: 'de-DE', name: 'Deutsch', file: 'de.json' }, { code: 'fr', language: 'fr-FR', name: 'Français', file: 'fr.json' }, ],codeis the identifier the app switches on,languageis the BCP 47 tag written intolang/hreflang, andnameis what the language picker shows. The picker reads this list, so a registered locale appears there with no further work. - Translate the values, never the keys. Keep the object shape identical to
en.json— the catalog guard test compares the two key sets and fails on a missing or invented key. - Run the guards.
cd apps/web && npx vitest run app/__tests__/localeCatalogs.test.ts cd apps/docs && npx vitest run __tests__/localeCatalogs.test.ts
This checks, for every locale: full key coverage againsten, identical placeholder names per message, no markup, and no unescaped@. - Check the long strings in the UI. German and French run 20–35% longer than English; buttons, switch rows and card headers are where that shows first.
Pick a register and hold it
Each locale should read as one voice. The shipped de catalog uses formal Sie throughout, because the most-read surfaces (unsubscribe, preference centre, double opt-in) are read by strangers who never chose Owlat. Follow the existing catalog rather than the habits of one file.
Writing translatable strings
Every visible string goes through t(). Import it once per component:
<script setup lang="ts">
const { t } = useI18n();
</script>
<template>
<p>{{ t('recipient.preferences.noTopics') }}</p>
</template>
Script-side strings — validation messages, error copy, useBackendOperation labels, useHead/useSeoMeta titles — go through the same t().
Anything captured once at setup takes a getter, not a value. A bare t('…') in an options object is evaluated while the component is being set up and then frozen there, so it keeps the locale that happened to be active at mount. useHead/useSeoMeta and useBackendOperation's label all accept () => t('…') and read it when they actually use it:
useHead({ title: () => t('auth.login.pageTitle') });
const { run } = useBackendOperation(api.auth.userProfiles.create, {
label: () => t('auth.register.createProfileOperation'),
});
Interpolate values, never concatenate sentences. Word order differs per language, so a string split into a prefix and a suffix around a value is untranslatable:
{ "intro": "Update your email preferences for {organization}." }
When part of a sentence carries markup, use <I18nT> with a named slot — not v-html, which would render an unescaped contact name or organization name straight into the page:
<I18nT keypath="recipient.preferences.intro" tag="span" scope="global">
<template #organization><strong>{{ contactInfo.teamName }}</strong></template>
</I18nT>
Escape a literal @. The message compiler reads @ as a linked-message marker, so an email placeholder is written you{'@'}example.com.
No HTML in message values. compilation.strictMessage rejects it at build time, so a smuggled <strong> is a failed build rather than a broken string.
Format dates and numbers against the active locale, not a pinned one:
const { t, locale } = useI18n();
new Intl.DateTimeFormat(locale.value, { dateStyle: 'long' }).format(sentAt);
A module that cannot call t() returns a key, not a sentence. The vocabulary tables under app/utils (deliverabilityRamp, deliverabilityMeasurement, breadcrumbRoutes, …) are pure modules with no component instance, so they hand back a catalog key — or { key, params } where the sentence interpolates figures — and whoever renders it turns it into words:
type LocalizedText = string | { key: string; params?: Record<string, unknown> };
const localized = (value: LocalizedText) =>
typeof value === 'string' ? t(value) : t(value.key, value.params ?? {});
Dropping one of those values straight into a template paints [object Object] or a raw key path at the reader, so every consumer is a render boundary — including the Nitro routes under server/, which have no vue-i18n and use localizeEn() (server/utils/localizedText.ts) to render the same keys in English for a script, a log line or curl.
Registries outside apps/web use the sharedPkg.* namespace — packages/shared vocabularies and Convex read models keep their keys under sharedPkg.<moduleName>.* so they can never collide with the file-derived namespaces above. Two patterns exist, chosen by who else reads the copy:
- Web-only copy stores keys outright (
operatingModes,snoozePresets,sendProviderCatalogcredential fields): the module carries the key, the web component renderst(key)— the same rule asapp/utils. - Copy with a non-web consumer keeps its English (
featureFlags— the setup CLI prints it;deliverabilityChecklist— Convex stores and mails it;adaptiveDashboardcards — a served read model): the module stays English, and the web resolves a derived key (useFeatureCopy(),useDashboardCardCopy()) with the registry's own English as the fallback for entries no shipped catalog can name, such as plugin-minted flags. A catalog test pins the two copies of the English together so they cannot drift.
What is extracted
Everything. The extraction landed surface by surface and is finished:
| Surface | State |
|---|---|
apps/web — dashboard, Postbox, delivery and admin, setup and desktop flows, auth, welcome, recipient-facing pages | Extracted |
apps/marketing — the public site | Extracted |
apps/docs — this site's chrome, and the pages themselves under content/{en,de}/ | Extracted and translated |
packages/ui — the shared layer's own copy (ui.*) | Extracted |
A new string that does not go through t() is a review comment, not a follow-up: a surface is either fully extracted or it produces a sentence in two languages.
The docs tree is the one place where a translation may legitimately lag, and it degrades on purpose — see A translation may lag; a page may not. Everywhere else en and de are held at parity by the catalog guards, which fail on a missing key rather than letting an English line surface mid-page.
Detection, the picker, and <html lang>
Browser-language detection is on in apps/web:
detectBrowserLanguage: { useCookie: true, cookieKey: 'owlat-locale' },
A first-time visitor is served the locale their browser asks for. The cookie is the load-bearing half: with no_prefix the URL carries no locale, so without it every reload would re-run detection and take the language back from anyone whose browser disagrees with their choice.
That choice is made in app/components/LanguagePicker.vue, on Preferences (/dashboard/preferences), beside Appearance — the same kind of setting: per-person, per-device, kept in this browser rather than on the account, so switching it on a phone leaves the desktop session alone. The picker lists the locales the module is configured with and switches through setLocale, which loads the target catalog, swaps the active locale and writes owlat-locale. The marketing site and this site have their own switchers in the header, where a prefixed URL makes the switch a navigation.
<html lang> follows the active locale. app.head.htmlAttrs.lang is not pinned in nuxt.config.ts — a value set there is baked into every page and would leave a German reader on a document that still claims lang="en", which is what a screen reader picks its voice from (WCAG 3.1.1). app/app.vue writes it from useLocaleHead() instead, through a useHead getter so it is re-evaluated when the picker switches locales rather than frozen at first render:
const localeHead = useLocaleHead();
useHead(() => ({ htmlAttrs: localeHead.value.htmlAttrs }));
Only htmlAttrs is taken in apps/web: with no_prefix every locale shares one URL, so the hreflang alternates useLocaleHead() can also emit would all point at the same page. The marketing and docs apps take link and meta from it too, because there the alternates are real URLs. And because apps/web runs ssr: false, its lang is written when the app boots rather than into the static shell.