Skip to content

Internationalization (i18n)

CleanSlice distributes translations across slices. Each slice manages its own locale files in a locales/ folder, and Nuxt's layer system merges them automatically at build time. The setup/i18n slice configures the base module, strategies, and date formatting -- but contains no translations itself.

How It Works

setup/i18n slice
  --> Configures @nuxtjs/i18n module
  --> Sets default locale, strategy, datetime formats
  --> Contains NO translations

Feature slices
  --> Each has locales/en.json, locales/fr.json, etc.
  --> Each nuxt.config.ts registers its locale files
  --> Nuxt layers merge everything at build time

Runtime
  --> $t('key') resolves from the merged set
  --> $d(date, 'short') formats dates per locale
  --> useI18n() for script-level access

Installation

bash
npm install -D @nuxtjs/i18n@next

Base i18n Slice Configuration

The setup slice configures the i18n module with global settings:

typescript
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const currentDir = dirname(fileURLToPath(import.meta.url));

export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  alias: {
    '#i18n': currentDir,
  },
  i18n: {
    // Path to Vue I18n config file
    vueI18n: 'i18n.config.ts',

    // No locale prefix in URLs (e.g., /about not /en/about)
    strategy: 'no_prefix',

    // Default language
    defaultLocale: 'en',

    // Allows HTML and special characters in translations
    compilation: {
      strictMessage: false,
    },

    // Detect user's language from browser
    detectBrowserLanguage: {
      useCookie: true,
      cookieKey: 'i18n_redirected',
      redirectOn: 'root',
    },
  },
});

Vue I18n Config

This file defines datetime formats and other Vue I18n options:

typescript
export default defineI18nConfig(() => ({
  legacy: false,
  locale: 'en',
  fallbackLocale: 'en',
  messages: {}, // Messages come from slice locale files

  datetimeFormats: {
    en: {
      short: {
        year: 'numeric',
        month: 'short',
        day: 'numeric',
      },
      long: {
        year: 'numeric',
        month: 'short',
        day: 'numeric',
        weekday: 'short',
        hour: 'numeric',
        minute: 'numeric',
      },
      time: {
        hour: '2-digit',
        minute: 'numeric',
        hour12: true,
      },
    },
    fr: {
      short: {
        year: 'numeric',
        month: 'short',
        day: 'numeric',
      },
      long: {
        year: 'numeric',
        month: 'short',
        day: 'numeric',
        weekday: 'short',
        hour: 'numeric',
        minute: 'numeric',
      },
      time: {
        hour: 'numeric',
        minute: 'numeric',
        hour12: false,
      },
    },
  },
}));

Adding Translations to a Slice

Every slice that needs translated text follows the same pattern:

1. Create a locales folder

slices/user/auth/
├── locales/
│   ├── en.json
│   └── fr.json
└── nuxt.config.ts

2. Register in nuxt.config.ts

typescript
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const currentDir = dirname(fileURLToPath(import.meta.url));

export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  alias: {
    '#auth': currentDir,
  },
  i18n: {
    langDir: '../locales',
    locales: [
      { code: 'en', file: 'en.json' },
      { code: 'fr', file: 'fr.json' },
    ],
  },
});

3. Write the locale files

json
{
  "welcome": "Welcome to the application",
  "USER_EXISTS_title": "User Already Registered",
  "USER_EXISTS_description": "It looks like you already have an account. Please log in.",
  "USER_NOT_AUTHORIZED_title": "Incorrect Email or Password",
  "USER_NOT_AUTHORIZED_description": "Please verify your credentials and try again, or contact {supportLink}.",
  "USER_BANNED_title": "Account Banned",
  "USER_BANNED_description": "Your account has been banned. Please contact {supportLink}."
}
json
{
  "locale.en": "English",
  "locale.fr": "French",
  "Home": "Home",
  "Save": "Save",
  "Cancel": "Cancel",
  "Delete": "Delete",
  "UNEXPECTED_ERROR_title": "Oops! Something Went Wrong",
  "UNEXPECTED_ERROR_description": "We encountered an unexpected error. Please try again."
}

WARNING

Translation keys must be unique across all slices, since Nuxt merges them into a single namespace. Use prefixes to avoid collisions -- for example, auth.welcome instead of welcome if multiple slices might define similar keys.

Using Translations

In Templates

vue
<template>
  <div>
    <!-- Simple translation -->
    <h1>{{ $t('welcome') }}</h1>

    <!-- With interpolation -->
    <p>{{ $t('USER_BANNED_description', { supportLink: '[email protected]' }) }}</p>

    <!-- Date formatting -->
    <span>{{ $d(new Date(item.createdAt), 'short') }}</span>

    <!-- Alternative date syntax -->
    <i18n-d tag="span" :value="new Date(item.createdAt)" format="long" />
  </div>
</template>

In Scripts and Composables

Use the useI18n composable (auto-imported by Nuxt):

typescript
const { t, d, locale } = useI18n();

// Translate a key
const message = t('welcome');

// Format a date
const formattedDate = d(new Date(), 'short');

// Switch locale
locale.value = 'fr';

Date Formatting Utility

A reusable utility for formatting dates:

typescript
export const formatDate = (date: string) => {
  const { d } = useI18n();
  return d(new Date(date), 'short');
};

Error Code Translation Pattern

CleanSlice uses a consistent convention for translating API error codes. When the API returns an error with a code field (like USER_NOT_FOUND), the error handling system looks up two translation keys:

  • {CODE}_title -- The error title shown in the toast
  • {CODE}_description -- The error description
json
{
  "USER_NOT_FOUND_title": "User Not Found",
  "USER_NOT_FOUND_description": "We couldn't find an account with the provided email."
}

The handleError utility resolves these automatically:

API throws: { code: "USER_NOT_FOUND", statusCode: 404 }
    --> handleError looks up $t('USER_NOT_FOUND_title')
    --> handleError looks up $t('USER_NOT_FOUND_description')
    --> Shows toast with translated message

Language Switcher Component

Here is a complete language switcher component:

vue
<script setup lang="ts">
const { locale, locales, setLocale } = useI18n();

const availableLocales = computed(() =>
  locales.value.filter((i) => i.code !== locale.value)
);
</script>

<template>
  <div class="flex gap-2">
    <Button
      v-for="loc in availableLocales"
      :key="loc.code"
      variant="ghost"
      size="sm"
      @click="setLocale(loc.code)"
    >
      {{ $t(`locale.${loc.code}`) }}
    </Button>
  </div>
</template>

URL Strategy Options

The strategy option in the i18n config controls how the locale appears in URLs:

StrategyURL PatternBest For
no_prefix/aboutSingle-language apps, or language stored in cookie
prefix/en/aboutAll URLs have locale prefix
prefix_except_default/about, /fr/aboutDefault language without prefix
prefix_and_default/en/about, /fr/aboutAll languages always prefixed

CleanSlice defaults to no_prefix because the language preference is stored in a cookie.

File Organization Summary

slices/
├── setup/
│   └── i18n/
│       ├── nuxt.config.ts       # Module config (no translations)
│       └── i18n.config.ts       # Datetime formats, fallback locale
├── common/
│   ├── locales/
│   │   ├── en.json              # Shared translations
│   │   └── fr.json
│   └── nuxt.config.ts
├── setup/error/
│   ├── locales/
│   │   └── en.json              # Error translations
│   └── nuxt.config.ts
└── user/auth/
    ├── locales/
    │   ├── en.json              # Auth translations
    │   └── fr.json
    └── nuxt.config.ts

What's Next?

Built with CleanSlice