Frontend i18n
The frontend uses the @nuxtjs/i18n module with centralized JSON locale files and a URL prefix strategy. Every user-facing string in the application is translated through this system, including navigation labels, form validation messages, and formatted currency values.
Configuration
The i18n module is configured in nuxt.config.ts:
i18n: {
defaultLocale: process.env.NUXT_PUBLIC_LOCALE || 'en',
locales,
strategy: 'prefix',
detectBrowserLanguage: {
useCookie: true,
cookieKey: 'i18n_redirected',
redirectOn: 'root',
},
},
| Setting | Value | What It Does |
|---|---|---|
strategy | 'prefix' | Locale appears in URL path: /en/dashboard, /fr/dashboard |
defaultLocale | 'en' | Fallback when no locale is detected |
detectBrowserLanguage.useCookie | true | Remembers user's language preference |
detectBrowserLanguage.redirectOn | 'root' | Only auto-redirect on root URL (/), not on deep links |
The locale definitions are imported from a dedicated config file:
import type { LocaleObject } from '@nuxtjs/i18n'
export const localeCodes = ['en', 'fr', 'es', 'it'] as const
export type LocaleCode = (typeof localeCodes)[number]
export const locales: LocaleObject[] = [
{ code: 'en', name: 'English', language: 'en-US', dir: 'ltr', file: 'en.json' },
{ code: 'fr', name: 'Français', language: 'fr-FR', dir: 'ltr', file: 'fr.json' },
{ code: 'es', name: 'Español', language: 'es-ES', dir: 'ltr', file: 'es.json' },
{ code: 'it', name: 'Italiano', language: 'it-IT', dir: 'ltr', file: 'it.json' },
]
The language field maps each short code to its BCP 47 tag. This mapping is used by the browser's Intl API for locale-aware formatting of currencies, dates, and numbers (e.g., en-US formats $1,234.56 while fr-FR formats 1 234,56 €).
The LocaleCode type is exported so other parts of the application can type locale values safely.
Locale File Structure
All translations live in frontend/i18n/locales/, one JSON file per locale:
frontend/i18n/locales/
├── en.json (~1,700 keys)
├── fr.json
├── es.json
└── it.json
Translation files use a hierarchical JSON structure with keys grouped by domain:
{
"navigation": {
"sidebar": {
"dashboard": "Dashboard",
"settings": "Settings",
"billing": "Billing",
"team": "Team"
},
"header": {
"docs": "Docs",
"pricing": "Pricing",
"blog": "Blog"
}
},
"auth": {
"sign-in": "Sign In",
"errors": {
"invalidEmail": "Please enter a valid email address",
"passwordTooShort": "Password must be at least 8 characters",
"passwordsMismatch": "Passwords do not match"
}
},
"billing": {
"subscription": {
"changePlanModal": {
"title": "Change Plan",
"proration": "Proration Preview",
"netAmount": "Net Amount"
}
}
}
}
Naming conventions:
| Convention | Example | Usage |
|---|---|---|
| Hierarchical nesting | navigation.sidebar.dashboard | Group related strings by domain and context |
| Hyphenated keys | sign-in, help-center | Multi-word UI labels |
| camelCase keys | invalidEmail, passwordTooShort | Validation error messages |
| Parameterized values | "Sign in with {provider}" | Dynamic content (see below) |
Using Translations in Components
The useI18n() Composable
Import the t function from useI18n() to translate strings in <script setup>:
<script setup lang="ts">
const { t } = useI18n()
const menuItems = computed(() => [
{ label: t('navigation.sidebar.dashboard'), to: localePath('/dashboard') },
{ label: t('navigation.sidebar.billing'), to: localePath('/docs/billing') },
{ label: t('navigation.sidebar.team'), to: localePath('/team') },
])
</script>
Template Shorthand
In templates, use $t() directly without importing:
<template>
<h1>{{ $t('auth.sign-in') }}</h1>
<p>{{ $t('newsletter.subscribe-to-our-newsletter') }}</p>
</template>
Locale-Prefixed URLs
Use useLocalePath() to generate URLs with the correct locale prefix:
<script setup lang="ts">
const localePath = useLocalePath()
</script>
<template>
<!-- Generates /en/pricing, /fr/pricing, etc. based on current locale -->
<NuxtLink :to="localePath('/pricing')">
{{ $t('navigation.header.pricing') }}
</NuxtLink>
</template>
Parameterized Translations
Use {variable} placeholders for dynamic content:
{
"billing": {
"subscription": {
"changePlanModal": {
"violationDetail": "{feature} usage ({usage}) exceeds plan limit ({limit})",
"effectiveDate": "Effective {date}"
}
}
}
}
Pass the values as a second argument to t():
<template>
<p>{{ t('billing.subscription.changePlanModal.violationDetail', {
feature: violation.featureName,
usage: violation.currentUsage,
limit: violation.planLimit,
}) }}</p>
<p>{{ t('billing.subscription.changePlanModal.effectiveDate', {
date: formatDate(preview.effectiveDate),
}) }}</p>
</template>
Zod Validation with i18n
SaaS4Builders uses a bridge pattern to connect Zod schema validation errors to the i18n system. Instead of hardcoding error messages, Zod schemas reference i18n keys as their error messages.
Defining Schemas with i18n Keys
export const loginInputSchema = z.object({
email: z.string().email({ message: 'auth.errors.invalidEmail' }),
password: z.string().min(8, { message: 'auth.errors.passwordTooShort' }),
remember: z.boolean().optional().default(false),
})
export const registerInputSchema = z
.object({
name: z
.string()
.min(2, { message: 'auth.errors.nameTooShort' })
.max(100, { message: 'auth.errors.fieldTooLong' }),
email: z.string().email({ message: 'auth.errors.invalidEmail' }),
password: z
.string()
.min(8, { message: 'auth.errors.passwordTooShort' })
.regex(passwordComplexityRegex, { message: 'auth.errors.passwordComplexity' }),
passwordConfirmation: z.string().min(8, { message: 'auth.errors.passwordTooShort' }),
})
.refine(
(data) => data.password === data.passwordConfirmation,
{
message: 'auth.errors.passwordsMismatch',
path: ['passwordConfirmation'],
}
)
The error message values ('auth.errors.invalidEmail', etc.) are not displayed as-is — they are used as lookup keys in the locale files.
The useZodI18nValidation Composable
This composable bridges Zod validation errors to vue-i18n translations:
export function useZodI18nValidation<T extends ZodSchema>(schema: T) {
const { t, te } = useI18n()
function validate(state: unknown): FormError[] {
const result = schema.safeParse(state)
if (result.success) return []
return (result.error as ZodError).issues.map((issue) => ({
name: issue.path.join('.'),
message: te(issue.message) ? t(issue.message) : issue.message,
}))
}
return { validate }
}
How it works:
- Runs
schema.safeParse(state)on form data - For each validation error, extracts the
message(which is an i18n key) - Uses
te()to check if the key exists in the current locale - If it exists, translates it via
t()— the user sees the localized message - If not, falls back to the raw message string
Using in Forms
The composable returns a validate function compatible with Nuxt UI's <UForm>:
<script setup lang="ts">
import { loginInputSchema } from '@foundation/auth'
const { t } = useI18n()
const { validate } = useZodI18nValidation(loginInputSchema)
const state = reactive({ email: '', password: '', remember: false })
</script>
<template>
<UForm :validate="validate" :state="state" @submit="onSubmit">
<UFormField :label="t('common.email')" name="email">
<UInput v-model="state.email" />
</UFormField>
<UFormField :label="t('common.password')" name="password">
<UInput v-model="state.password" type="password" />
</UFormField>
<!-- Validation errors are automatically translated -->
</UForm>
</template>
Currency and Date Formatting
The i18n system extends beyond text translation to locale-aware formatting of currencies and dates, using the BCP 47 locale tags defined in locales.config.ts.
Currency Formatting
The formatMoney utility uses Intl.NumberFormat with the current locale for proper currency display:
export function formatMoney(money: Money, locale?: string): string {
const effectiveLocale = locale ?? getDefaultLocale()
const minorUnits = getMinorUnits(money.currency)
const divisor = 10 ** minorUnits
const displayAmount = money.amountCents / divisor
const formatter = getFormatter(effectiveLocale, money.currency, minorUnits)
return formatter.format(displayAmount)
}
The same amount renders differently depending on the locale:
| Expression | Result |
|---|---|
formatMoney({ amountCents: 1999, currency: 'EUR' }, 'en-US') | €19.99 |
formatMoney({ amountCents: 1999, currency: 'EUR' }, 'fr-FR') | 19,99 € |
formatMoney({ amountCents: 1000, currency: 'JPY' }, 'en-US') | ¥1,000 |
The utility handles ISO 4217 minor units correctly — zero-decimal currencies like JPY and KRW use no decimal places, while three-decimal currencies like KWD and BHD use three.
In components, pass the current locale from useI18n():
<script setup lang="ts">
const { locale } = useI18n()
const formattedPrice = computed(() =>
formatMoneyFromCents(plan.priceCents, plan.currency, locale.value)
)
</script>
Intl.NumberFormat instances are cached by locale:currency key for performance — formatting the same currency in the same locale reuses the formatter.
Date Formatting
Dates are formatted using the native toLocaleDateString() with the current locale:
const { locale } = useI18n()
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString(locale.value, {
dateStyle: 'medium',
})
}
// en-US: "Mar 15, 2026"
// fr-FR: "15 mars 2026"
How to Add a New Translation Key
Step 1: Choose the right category in the JSON hierarchy. Browse frontend/i18n/locales/en.json to find where your key belongs (e.g., billing.subscription, auth.errors, navigation.sidebar).
Step 2: Add the key to all four locale files:
{
"reports": {
"exportSuccess": "Report exported successfully",
"noData": "No data available for the selected period"
}
}
{
"reports": {
"exportSuccess": "Rapport exporté avec succès",
"noData": "Aucune donnée disponible pour la période sélectionnée"
}
}
{
"reports": {
"exportSuccess": "Informe exportado exitosamente",
"noData": "No hay datos disponibles para el período seleccionado"
}
}
{
"reports": {
"exportSuccess": "Report esportato con successo",
"noData": "Nessun dato disponibile per il periodo selezionato"
}
}
Step 3: Use it in your component:
<template>
<p>{{ $t('reports.exportSuccess') }}</p>
</template>
Or in script:
const { t } = useI18n()
const message = t('reports.noData')
How to Add a 5th Locale
Adding a new locale (e.g., German de) requires changes in both the frontend and backend.
Frontend Steps
1. Add the locale definition in frontend/i18n/locales.config.ts:
export const localeCodes = ['en', 'fr', 'es', 'it', 'de'] as const
export const locales: LocaleObject[] = [
{ code: 'en', name: 'English', language: 'en-US', dir: 'ltr', file: 'en.json' },
{ code: 'fr', name: 'Français', language: 'fr-FR', dir: 'ltr', file: 'fr.json' },
{ code: 'es', name: 'Español', language: 'es-ES', dir: 'ltr', file: 'es.json' },
{ code: 'it', name: 'Italiano', language: 'it-IT', dir: 'ltr', file: 'it.json' },
{ code: 'de', name: 'Deutsch', language: 'de-DE', dir: 'ltr', file: 'de.json' },
]
2. Create the locale file at frontend/i18n/locales/de.json with all translated keys. The easiest approach is to copy en.json as a starting point and translate each value.
3. Update the language selector if your UI has a manual locale switcher component — the new locale will appear automatically if the selector reads from the locales config.
Backend Steps
See Backend Translations — How to Add a 5th Locale for the corresponding backend changes (env variable, translation files, database translations).
Gotchas
The @ Symbol in Translation Values
Vue-i18n treats @ as a linked message operator. If your translation contains a literal @ (e.g., an email address), you must escape it:
{
"support": {
"contactEmail": "Contact us at support{'@'}example.com"
}
}
Without the escape ({'@'}), vue-i18n will try to interpret everything after @ as a reference to another translation key, leading to unexpected output or errors.
Translation Key Consistency
When using i18n keys in Zod schemas, make sure the key exists in all four locale files. If a key is missing:
te()returnsfalsefor that locale- The raw key string (e.g.,
auth.errors.invalidEmail) is shown to the user instead of the translated message - This is a graceful degradation, not a crash — but it looks unprofessional
What's Next
- Backend Translations — Laravel lang file structure, model translations, and validation of translatable input
- Internationalization Overview — Architecture summary and locale detection
Backend Translations
Laravel translation files, model translations with Astrotomic Translatable, validation of translatable input, and how to add new translation keys or locales.
Testing Overview
Testing philosophy, toolchain, directory structure, quick-start commands, and CI pipeline for the SaaS4Builders boilerplate.