Skip to content
SaaS4Builders
Internationalization

Frontend i18n

Nuxt i18n module setup, JSON locale files, translation usage in components, Zod validation integration, locale-aware currency formatting, and how to extend the system.

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:

frontend/nuxt.config.ts
i18n: {
  defaultLocale: process.env.NUXT_PUBLIC_LOCALE || 'en',
  locales,
  strategy: 'prefix',
  detectBrowserLanguage: {
    useCookie: true,
    cookieKey: 'i18n_redirected',
    redirectOn: 'root',
  },
},
SettingValueWhat It Does
strategy'prefix'Locale appears in URL path: /en/dashboard, /fr/dashboard
defaultLocale'en'Fallback when no locale is detected
detectBrowserLanguage.useCookietrueRemembers 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:

frontend/i18n/locales.config.ts
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:

frontend/i18n/locales/en.json
{
  "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:

ConventionExampleUsage
Hierarchical nestingnavigation.sidebar.dashboardGroup related strings by domain and context
Hyphenated keyssign-in, help-centerMulti-word UI labels
camelCase keysinvalidEmail, passwordTooShortValidation error messages
Parameterized values"Sign in with {provider}"Dynamic content (see below)
All translations are centralized — there are no per-feature locale files. This keeps translations easy to find and prevents duplicate keys across features.

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

frontend/features/foundation/auth/schemas.ts
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:

frontend/common/composables/useZodI18nValidation.ts
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:

  1. Runs schema.safeParse(state) on form data
  2. For each validation error, extracts the message (which is an i18n key)
  3. Uses te() to check if the key exists in the current locale
  4. If it exists, translates it via t() — the user sees the localized message
  5. 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>
This pattern keeps validation logic and translation concerns cleanly separated. Zod schemas are pure data validation — they know nothing about the UI or the current locale. The bridge composable handles the translation layer.

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:

frontend/common/utils/_lib/money.ts
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:

ExpressionResult
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"
  }
}

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:

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).

Both the backend and frontend locale lists must stay in sync. If you add a locale to one side but not the other, the backend may return untranslated messages for that locale, or the frontend may offer a language the API cannot serve.

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() returns false for 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