Features

Internationalization (i18n)

Add multi-language support to your Crouton collections with automatic translation handling
Status: Stable

Add comprehensive multi-language support to your Nuxt Crouton applications with the @friendlyinternet/nuxt-crouton-i18n addon package. This package provides team-specific translation overrides, development tools, and automatic locale management.

Quick Start

Installation

First, install the i18n package:

pnpm add @friendlyinternet/nuxt-crouton-i18n

Then add it to your Nuxt config:

// nuxt.config.ts
export default defineNuxtConfig({
  extends: [
    '@friendlyinternet/nuxt-crouton',
    '@friendlyinternet/nuxt-crouton-i18n'
  ]
})
Important: The i18n addon is a layer extension, not a standalone package. You must extend both the base layer and the i18n addon.

Mark Fields as Translatable

Configure which fields in your collections should be translatable:

// crouton.config.js
export default {
  translations: {
    collections: {
      products: ['name', 'description'],  // These fields are translatable
      posts: ['title', 'body', 'excerpt']
    }
  }
}

Generated Forms with Translations

When you generate forms with translations enabled, Crouton automatically creates translation inputs:

<!-- Auto-generated when using --no-translations=false -->
<template>
  <UForm @submit="handleSubmit">
    <!-- Regular fields -->
    <UFormField label="SKU" name="sku">
      <UInput v-model="state.sku" />
    </UFormField>

    <!-- Translatable fields -->
    <CroutonI18nInput
      v-model="state.translations"
      :fields="['name', 'description']"
      :default-values="{
        name: state.name,
        description: state.description
      }"
    />
  </UForm>
</template>

Query with Locale

Queries automatically fetch data for the current locale:

<script setup lang="ts">
const { locale } = useI18n()

// Auto-fetches for current locale
const { items } = await useCollectionQuery('shopProducts', {
  query: computed(() => ({ locale: locale.value }))
})

// Auto-refetches when locale changes!
</script>

Display Translated Fields

Use the useEntityTranslations composable to display translated content:

<script setup lang="ts">
const { t } = useEntityTranslations()
const product = { name: 'Product', translations: { fr: { name: 'Produit' } } }
</script>

<template>
  <!-- Shows "Product" in English, "Produit" in French -->
  <div>{{ t(product, 'name') }}</div>
</template>

Package Overview

Package: @friendlyinternet/nuxt-crouton-i18nVersion: 1.3.0 Type: Nuxt Layer (Addon) Dependencies: @nuxtjs/i18n v9.0.0

Features

  • 🌍 Multi-language input components with automatic locale switching
  • 🔄 Auto-sync with English as primary language
  • 📝 Team-specific translation overrides
  • 🎯 Built-in support for EN, NL, FR (configurable)
  • ⚡ Inherits all CRUD features from base layer
  • 🛠️ Development mode for inline translation editing
  • 💾 Database-backed translation management
  • 🎨 Rich text editor support for translated content

Locale File Sources

Crouton's i18n system merges translations from multiple sources. Understanding this merge order is essential for managing translations effectively.

Translation Merge Order

Translations are loaded and merged in this order (last wins for conflicting keys):

1. nuxt-crouton/i18n/locales/           (Crouton component strings)
2. nuxt-crouton-supersaas/i18n/locales/ (App-level strings from SuperSaaS package)
3. layers/[domain]/i18n/locales/        (Domain-specific strings)
4. app/i18n/locales/                    (App-level overrides)
5. Database (translationsUi table)      (Runtime overrides)

Translation Key Ownership

PackageKeysExample
nuxt-croutonCrouton component stringscrouton.table.search, crouton.form.save
nuxt-crouton-supersaasApp-level stringscommon.save, auth.signIn, navigation.dashboard
nuxt-crouton-i18nAdmin UI stringsi18n.admin.addKey, i18n.admin.override
layers/domainDomain-specificbookings.form.location, bookings.status.confirmed

Resolution Order (Runtime)

When $t('common.save') is called:

1. DB: Team override for current team?
   ↓ (not found)
2. DB: System default?
   ↓ (not found)
3. Locale files (merged, last wins):
   a. nuxt-crouton/i18n/locales/en.json
   b. nuxt-crouton-supersaas/i18n/locales/en.json
   c. layers/bookings/i18n/locales/en.json
   d. app/i18n/locales/en.json (if exists)
   ↓ (found in supersaas)
4. Return value: "Save"

Seeding Translations to Database

The @friendlyinternet/nuxt-crouton-i18n package provides a CLI command to populate your database with translations from locale files.

Seed Command

pnpm crouton i18n seed

What It Does

  1. Reads locale files from all configured sources
  2. Inserts translations into the translationsUi table
  3. Sets teamId: null (system default)
  4. Sets isOverrideable: true (allows team overrides)
  5. Skips existing keys (no duplicates on re-run)

When to Run

  • Initial setup: After installing the i18n package
  • After updates: When locale files are updated
  • After adding layers: When new domain translations are added

Options

# Seed all locales
pnpm crouton i18n seed

# Seed specific locale
pnpm crouton i18n seed --locale=en

# Force overwrite existing
pnpm crouton i18n seed --force

# Dry run (show what would be inserted)
pnpm crouton i18n seed --dry-run

Default Locale Configuration

The package comes pre-configured with three locales:

// Built-in configuration
{
  locales: [
    { code: 'en', name: 'English', file: 'en.json' },
    { code: 'nl', name: 'Nederlands', file: 'nl.json' },
    { code: 'fr', name: 'Français', file: 'fr.json' }
  ],
  defaultLocale: 'en',
  strategy: 'no_prefix'
}

Customizing Locales

Override the default locales in your nuxt.config.ts:

export default defineNuxtConfig({
  extends: [
    '@friendlyinternet/nuxt-crouton',
    '@friendlyinternet/nuxt-crouton-i18n'
  ],

  i18n: {
    locales: [
      { code: 'en', name: 'English', file: 'en.json' },
      { code: 'es', name: 'Español', file: 'es.json' },
      { code: 'de', name: 'Deutsch', file: 'de.json' }
    ]
  }
})

Adding Translations to Nuxt Layers

Automatic Setup: When you generate collections with translations enabled, Crouton automatically:
  • Creates the i18n/locales/ folder structure
  • Generates starter locale files (en.json, nl.json, fr.json)
  • Adds the correct i18n config to your layer's nuxt.config.ts

If you need to manually configure i18n in a layer, use relative paths for langDir:

// layers/my-layer/nuxt.config.ts
export default defineNuxtConfig({
  i18n: {
    locales: [
      { code: 'en', file: 'en.json' },
      { code: 'nl', file: 'nl.json' },
      { code: 'fr', file: 'fr.json' }
    ],
    langDir: './locales'  // ✅ Relative to restructureDir 'i18n'
  }
})

Your locale files should be placed at:

layers/my-layer/
├── i18n/
│   └── locales/
│       ├── en.json
│       ├── nl.json
│       └── fr.json
└── nuxt.config.ts
Important: The langDir path is relative to the restructureDir (default: i18n). Use ./locales, NOT ./i18n/locales - the latter will result in a doubled path error.
Critical: Never use absolute paths for langDir in layers. Absolute paths will work in development but fail in production.
// ❌ WRONG - Will break in production
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'

const currentDir = fileURLToPath(new URL('.', import.meta.url))

export default defineNuxtConfig({
  i18n: {
    langDir: join(currentDir, 'i18n/locales')  // ❌ Absolute path
  }
})

Components Reference

The package provides 11 components with the CroutonI18n prefix for managing translations.

CroutonI18nDisplay

Display translated content with automatic fallback to English.

Props

interface DisplayProps {
  translations: Record<string, string>  // Translation values by locale
  languages?: string[]                   // Override available languages
}

Features

  • Badge Display: Shows translation status for each language
  • Popover Preview: Displays translations up to 200 characters
  • Modal View: For longer translations
  • Copy to Clipboard: Quick copy functionality
  • Character/Word Count: Metadata display
  • Fallback Indication: Shows when using English fallback

Usage

<script setup lang="ts">
const product = {
  name: 'Product',
  translations: {
    en: 'English Product Name',
    nl: 'Nederlandse Productnaam',
    fr: 'Nom du produit français'
  }
}
</script>

<template>
  <!-- Basic usage -->
  <CroutonI18nDisplay :translations="product.translations" />

  <!-- Custom languages -->
  <CroutonI18nDisplay
    :translations="product.translations"
    :languages="['en', 'nl']"
  />
</template>

Badge Colors

  • Primary: Translation exists for this locale
  • Neutral: No translation available
  • Error: Translation validation failed

CroutonI18nInput

Multi-language input component for forms supporting both single and multi-field translations.

Props

interface InputProps {
  modelValue: SingleFieldValue | MultiFieldValue | null
  fields: string[]                          // Fields to translate
  label?: string                            // Form label
  error?: string | boolean                  // Validation error
  defaultValues?: Record<string, string>    // Default values for fallback
  fieldComponents?: Record<string, string>  // Custom components per field
}

type SingleFieldValue = Record<string, string>
type MultiFieldValue = Record<string, Record<string, string>>

Features

  • Language Tabs: Switch between locales with visual indicators
  • Completion Status: Shows which locales are complete
  • Required Fields: English marked as required
  • Fallback Hints: Shows English text when editing other languages
  • Custom Components: Support for UInput, UTextarea, CroutonEditorSimple
  • Validation: Highlights errors for required fields

Single Field Mode

<script setup lang="ts">
const state = ref({
  name: '',
  translations: {} // { en: "English", nl: "Nederlands", fr: "Français" }
})
</script>

<template>
  <CroutonI18nInput
    v-model="state.translations"
    :fields="[]"
  />
</template>

Multi-Field Mode

<script setup lang="ts">
const state = ref({
  name: 'Product',
  description: 'Description',
  translations: {} // { en: { name: "...", description: "..." }, nl: { ... } }
})
</script>

<template>
  <CroutonI18nInput
    v-model="state.translations"
    :fields="['name', 'description']"
    :default-values="{
      name: state.name,
      description: state.description
    }"
  />
</template>

Custom Field Components

<script setup lang="ts">
const state = ref({
  title: '',
  content: '<p></p>',
  translations: {}
})
</script>

<template>
  <CroutonI18nInput
    v-model="state.translations"
    :fields="['title', 'content']"
    :default-values="{
      title: state.title,
      content: state.content
    }"
    :field-components="{
      content: 'CroutonEditorSimple',  // Rich text editor
      title: 'UInput'                   // Default input
    }"
  />
</template>

Supported Field Components:

  • UInput - Single-line text (default)
  • UTextarea - Multi-line text
  • CroutonEditorSimple - Rich text editor

CroutonI18nInputWithEditor

Specialized input component with built-in rich text editor support.

Props

interface InputWithEditorProps {
  modelValue: Record<string, string>  // Translation values
  fields: string[]                     // For backwards compatibility
  label?: string
  error?: string | boolean
  useRichText?: boolean                // Enable rich text editor
}

Features

  • Rich Text Toggle: Switch between plain text and rich editor
  • English Reference: Shows original when editing translations
  • Height Control: Fixed 256px height for editor
  • Visual Indicators: Border colors for validation states

Usage

<script setup lang="ts">
const state = ref({
  content: '<p>English content</p>',
  translations: {}
})
</script>

<template>
  <!-- With rich text editor -->
  <CroutonI18nInputWithEditor
    v-model="state.translations"
    :fields="['content']"
    :use-rich-text="true"
  />

  <!-- Plain text input -->
  <CroutonI18nInputWithEditor
    v-model="state.translations"
    :fields="['content']"
    :use-rich-text="false"
  />
</template>

CroutonI18nLanguageSwitcher

Dropdown selector for switching between available languages.

Features

  • Locale Selection: Uses Nuxt UI's ULocaleSelect
  • Flag Icons: Custom emoji flags per locale
  • Selection Indicator: Check mark for current language
  • Ghost Variant: Minimal UI styling

Default Flags

{
  en: '🌹',  // English
  nl: '🦁',  // Dutch
  fr: '🐔'   // French
}

Usage

<template>
  <!-- Basic usage -->
  <CroutonI18nLanguageSwitcher />
</template>

The component automatically:

  1. Reads available locales from i18n config
  2. Updates the current locale on selection
  3. Triggers reactive updates across the app

CroutonI18nLanguageSwitcherIsland

Floating language switcher for overlay/island usage.

Features

  • Fixed Position: Top-right corner (z-index 50)
  • Dropdown Menu: UDropdownMenu with custom styling
  • Dark Theme: Black background with hover effects
  • Uppercase Labels: Language codes in uppercase
  • Check Indicator: Shows current selection

Usage

<template>
  <!-- Floating switcher in top-right -->
  <CroutonI18nLanguageSwitcherIsland />
</template>

CroutonI18nUiForm

Form component for managing UI translation overrides.

Props

interface UiFormProps {
  action: 'create' | 'update' | 'delete'
  activeItem?: any                       // Item being edited
  loading?: string                       // Loading state
  collection: string                     // Collection name
}

Features

  • Delete Confirmation: Modal with warning icon
  • Translation Preview: Shows translation being deleted
  • Form Validation: Zod schema validation
  • Key Path Input: Auto-disabled when editing
  • Category Field: Organizes translations

Usage

<script setup lang="ts">
const action = ref<'create' | 'update' | 'delete'>('create')
const activeItem = ref(null)
</script>

<template>
  <CroutonI18nUiForm
    :action="action"
    :active-item="activeItem"
    collection="translationsUi"
  />
</template>

Form Fields

  1. Key Path (required): Dot-notation key (e.g., table.search)
  2. Category (required): Grouping category (e.g., table)
  3. Translations (required): Multi-language values
  4. Description (optional): Documentation for the translation

CroutonI18nUiList

List view for managing UI translations with Crouton table integration.

Features

  • CroutonTable Integration: Uses standard table component
  • Custom Cell Rendering: Special handling for translation values
  • Create Button: Built-in creation workflow
  • Auto-Refresh: Reactive updates

Default Columns

[
  { key: 'keyPath', label: 'Key Path' },
  { key: 'category', label: 'Category' },
  { key: 'values', label: 'Translations' },
  { key: 'description', label: 'Description' },
  { key: 'actions', label: 'Actions' }
]

Usage

<template>
  <CroutonI18nUiList />
</template>

The component automatically:

  • Fetches translations using useCollectionQuery('translationsUi')
  • Displays translations with CroutonI18nDisplay component
  • Provides CRUD actions via CroutonTable

CroutonI18nCardsMini

Badge component showing single locale translation status.

Props

interface CardsMiniProps {
  locale: string        // Locale code (en, nl, fr)
  hasTranslation: boolean  // Whether translation exists
}

Features

  • Color Coding: Primary for exists, neutral for missing
  • Uppercase Labels: Locale codes in uppercase
  • Small Size: Compact badge display

Usage

<template>
  <CroutonI18nCardsMini locale="en" :has-translation="true" />
  <CroutonI18nCardsMini locale="nl" :has-translation="false" />
</template>

CroutonI18nListCards

Container component showing translation status for all locales.

Props

interface ListCardsProps {
  item: any           // Entity with translations
  fields: string[]    // Fields to check for translations
}

Features

  • Multi-Locale Display: Shows all configured locales
  • Field-Based Validation: Checks if any field has translation
  • Auto-Layout: Flex gap layout

Usage

<script setup lang="ts">
const product = {
  translations: {
    en: { name: 'Product', description: 'Description' },
    nl: { name: 'Product' }  // description missing
  }
}
</script>

<template>
  <CroutonI18nListCards
    :item="product"
    :fields="['name', 'description']"
  />
  <!-- Shows: EN (complete), NL (incomplete), FR (missing) -->
</template>

CroutonI18nDevModeToggle

Development tool for managing missing translations with inline editing.

Features

  • Dev-Only: Only visible in development mode
  • Translation Scanner: Finds [key] patterns in DOM
  • Click-to-Edit: Modal for adding missing translations
  • Visual Highlighting: Red background for missing translations
  • Auto-Refresh: Updates UI after saving

Usage

<template>
  <!-- Add to your layout for dev mode -->
  <CroutonI18nDevModeToggle />
</template>

Workflow

  1. Click "Enable translation dev mode"
  2. Scanner finds all [keyPath] patterns
  3. Click any highlighted translation
  4. Modal opens for that key
  5. Enter translation
  6. Save to database
  7. Page refreshes with new translation

CroutonI18nDevWrapper

Wrapper component for inline translation editing in development.

Props

interface DevWrapperProps {
  translationKey: string     // Translation key path
  value?: string              // Current value
  locale?: string             // Current locale (default: 'en')
  level?: 'system' | 'team' | 'missing'  // Translation source
  teamId?: string             // Team ID for team translations
}

Features

  • Inline Editing: Click to edit, Enter to save, Escape to cancel
  • Visual Indicators: Color-coded by source level
  • Pulse Animation: Missing translations pulse red
  • Quick Actions: Check and X buttons for save/cancel

Level Indicators

  • System: Blue background - from system translations
  • Team: Green background - team override
  • Missing: Red background with pulse - no translation found

Usage

<script setup lang="ts">
const { currentTeam } = useTeam()
</script>

<template>
  <!-- Wrap translatable content -->
  <CroutonI18nDevWrapper
    translation-key="common.save"
    :value="$t('common.save')"
    level="system"
  >
    {{ $t('common.save') }}
  </CroutonI18nDevWrapper>

  <!-- Team-specific translation -->
  <CroutonI18nDevWrapper
    translation-key="dashboard.title"
    :value="title"
    level="team"
    :team-id="currentTeam?.id"
  >
    {{ title }}
  </CroutonI18nDevWrapper>
</template>

Composables Reference

useT()

Enhanced translation composable with team override support and development mode.

Type Signature

interface TranslationOptions {
  params?: Record<string, any>
  fallback?: string
  category?: string
  mode?: 'system' | 'team'
  placeholder?: string
}

function useT(): {
  t: (key: string, options?: TranslationOptions) => string
  tString: (key: string, options?: TranslationOptions) => string
  tContent: (entity: any, field: string, preferredLocale?: string) => string
  tInfo: (key: string, options?: TranslationOptions) => TranslationInfo
  hasTranslation: (key: string) => boolean
  getAvailableLocales: (key: string) => string[]
  getTranslationMeta: (key: string) => TranslationMeta
  refreshTranslations: () => Promise<void>
  locale: Ref<string>
  isDev: boolean
  devModeEnabled: Ref<boolean>
}

Features

  • Team Overrides: Checks team translations before system
  • Auto-Caching: Caches team translations per team/locale
  • Parameter Substitution: {key} replacement in strings
  • Fallback Support: Custom fallback values
  • Missing Indicators: Returns [key] when not found
  • Content Translation: Direct entity field translation
  • Metadata Access: Get translation info for admin UIs

Basic Usage

<script setup lang="ts">
const { t, tString, tContent } = useT()

// Basic translation
const saveLabel = t('common.save')

// With parameters
const greeting = t('messages.hello', {
  params: { name: 'John' }
})

// String-only (for non-template contexts)
const title = tString('page.title')

// Entity field translation
const productName = tContent(product, 'name')
</script>

Team Override Example

<script setup lang="ts">
const { t } = useT()
const { currentTeam } = useTeam()

// 1. Checks team translations for current team
// 2. Falls back to system translation
// 3. Returns [key] if not found
const label = t('dashboard.welcome')
</script>

<template>
  <h1>{{ label }}</h1>
</template>

Content Translation

<script setup lang="ts">
const { tContent } = useT()
const { locale } = useI18n()

const product = {
  name: 'Product',
  translations: {
    en: { name: 'English Name', description: 'English Description' },
    nl: { name: 'Nederlandse Naam' }
  }
}

// In English: "English Name"
// In Dutch: "Nederlandse Naam"
// In French: "English Name" (fallback)
const name = tContent(product, 'name')

// With explicit locale
const dutchName = tContent(product, 'name', 'nl')
</script>

useEntityTranslations()

Simple composable for translating entity fields based on current locale.

Type Signature

function useEntityTranslations(): {
  t: (entity: any, field: string) => string
}

Usage

<script setup lang="ts">
const { t } = useEntityTranslations()
const { locale } = useI18n()

const product = {
  name: 'Product',
  description: 'English description',
  translations: {
    nl: {
      name: 'Product',
      description: 'Nederlandse beschrijving'
    }
  }
}

// Automatically uses current locale
const name = t(product, 'name')
const description = t(product, 'description')
</script>

<template>
  <div>
    <h2>{{ name }}</h2>
    <p>{{ description }}</p>
  </div>
</template>

Fallback Behavior

// Priority:
// 1. translations[currentLocale][field]
// 2. entity[field] (root field value)
// 3. Empty string

const product = {
  name: 'Default Name',
  translations: {
    nl: { name: 'Nederlandse Naam' }
  }
}

// In NL locale: "Nederlandse Naam"
// In EN locale: "Default Name" (fallback to root)
// In FR locale: "Default Name" (fallback to root)
t(product, 'name')

useTranslationsUi()

Configuration composable for the translationsUi collection.

Type Signature

function useTranslationsUi(): {
  schema: ZodSchema
  columns: ColumnDefinition[]
  defaultValue: TranslationDefaults
  defaultPagination: PaginationConfig
  config: CollectionConfig
  collection: string
}

Returns

  • schema: Zod validation schema
  • columns: Table column definitions
  • defaultValue: Default form values
  • defaultPagination: Pagination settings
  • config: Full collection configuration
  • collection: Collection name ('translationsUi')

Usage

<script setup lang="ts">
const { schema, columns, defaultValue } = useTranslationsUi()

const state = ref({ ...defaultValue })

const handleSubmit = async () => {
  const valid = schema.parse(state.value)
  await $fetch('/api/translations-ui', {
    method: 'POST',
    body: valid
  })
}
</script>

<template>
  <UForm :schema="schema" :state="state" @submit="handleSubmit">
    <UFormField label="Key Path" name="keyPath">
      <UInput v-model="state.keyPath" />
    </UFormField>
    <!-- ... -->
  </UForm>
</template>

Database Schema

translationsUi Table

The package adds a translations_ui table for storing system and team-specific translations.

{
  id: string (primary key)
  userId: string (not null)
  teamId: string (nullable)  // null = system translation
  namespace: string (default: 'ui')
  keyPath: string (not null)
  category: string (not null)
  values: Record<string, string> (JSON)
  description: string (nullable)
  isOverrideable: boolean (default: true)
  createdAt: timestamp
  updatedAt: timestamp
}

Unique Constraint

unique(teamId, namespace, keyPath)

This ensures one translation per team per key path.

Translation Types

  1. System Translations: teamId = null, isOverrideable = true
  2. Team Overrides: teamId = <team-id>

Integration Examples

Complete Product Management

Basic form setup:

<script setup lang="ts">
const props = defineProps<{ action: 'create' | 'update', item?: Product }>()
const { create, update } = useCollectionMutation('products')

const state = ref({
  sku: props.item?.sku || '',
  name: props.item?.name || '',
  price: props.item?.price || 0,
  translations: props.item?.translations || {}
})

const handleSubmit = async () => {
  if (props.action === 'create') {
    await create(state.value)
  } else {
    await update(props.item!.id, state.value)
  }
}
</script>

<template>
  <UForm :state="state" @submit="handleSubmit">
    <UFormField label="SKU" name="sku" required>
      <UInput v-model="state.sku" />
    </UFormField>

    <UFormField label="Name (English)" name="name" required>
      <UInput v-model="state.name" />
    </UFormField>

    <CroutonButton :action="action" collection="products" />
  </UForm>
</template>

With translation inputs:

<template>
  <UForm :state="state" @submit="handleSubmit">
    <!-- Default language fields -->
    <UFormField label="Name (English)" name="name" required>
      <UInput v-model="state.name" />
    </UFormField>

    <UFormField label="Description (English)" name="description">
      <UTextarea v-model="state.description" rows="3" />
    </UFormField>

    <!-- Translations component -->
    <UFormField label="Translations">
      <CroutonI18nInput
        v-model="state.translations"
        :fields="['name', 'description']"
        :default-values="{ name: state.name, description: state.description }"
        :field-components="{ description: 'UTextarea' }"
      />
    </UFormField>

    <CroutonButton :action="action" collection="products" />
  </UForm>
</template>

Product Display with Translations

<script setup lang="ts">
const { locale } = useI18n()
const { items } = await useCollectionQuery('products', {
  query: computed(() => ({
    locale: locale.value
  }))
})

const { t } = useEntityTranslations()
</script>

<template>
  <div class="grid gap-4">
    <div v-for="product in items" :key="product.id" class="card">
      <!-- Translated name -->
      <h3>{{ t(product, 'name') }}</h3>

      <!-- Translated description -->
      <p>{{ t(product, 'description') }}</p>

      <!-- Translation status badges -->
      <CroutonI18nListCards
        :item="product"
        :fields="['name', 'description']"
      />

      <!-- Non-translated fields -->
      <div class="text-sm text-gray-500">
        SKU: {{ product.sku }} | Price: {{ product.price }}
      </div>
    </div>
  </div>
</template>

Admin Translation Management

<script setup lang="ts">
// Full admin page for UI translations
definePageMeta({
  middleware: 'admin'
})
</script>

<template>
  <div class="space-y-6">
    <div class="flex justify-between items-center">
      <h1>UI Translations</h1>
      <CroutonI18nLanguageSwitcher />
    </div>

    <!-- Translation management table -->
    <CroutonI18nUiList />

    <!-- Dev mode toggle for testing -->
    <CroutonI18nDevModeToggle />
  </div>
</template>

Best Practices

1. Always Provide English

English is the fallback language - always provide English translations.

<!-- ✅ Good -->
<CroutonI18nInput
  v-model="state.translations"
  :fields="['name']"
  :default-values="{ name: state.name }"
/>

<!-- ❌ Bad - No English default -->
<CroutonI18nInput
  v-model="state.translations"
  :fields="['name']"
/>

2. Use Entity Translations for Content

For content fields, store translations in the entity:

// ✅ Good - Entity field translations
{
  id: '123',
  name: 'Product',
  translations: {
    nl: { name: 'Product' },
    fr: { name: 'Produit' }
  }
}

// ❌ Bad - Don't use UI translations for content
// UI translations are for interface labels only

3. Organize Translation Keys

Use dot notation and categories:

// ✅ Good
'common.save'
'common.cancel'
'table.search'
'table.filter'
'dashboard.title'

// ❌ Bad
'save'
'cancelButton'
'SearchTable'

4. Leverage Fallback Behavior

<script setup lang="ts">
// ✅ Good - Relies on automatic fallback
const { t } = useEntityTranslations()
const name = t(product, 'name')

// ❌ Bad - Manual fallback logic
const name = product.translations?.[locale.value]?.name || product.name || ''
</script>

5. Use Rich Text for Long Content

<!-- ✅ Good - Rich text for long content -->
<CroutonI18nInput
  v-model="state.translations"
  :fields="['content']"
  :field-components="{ content: 'CroutonEditorSimple' }"
/>

<!-- ❌ Bad - Plain text for HTML content -->
<CroutonI18nInput
  v-model="state.translations"
  :fields="['content']"
/>

6. Enable Dev Mode During Development

<template>
  <!-- ✅ Good - Always include in dev layout -->
  <CroutonI18nDevModeToggle />

  <!-- Quickly identify and fix missing translations -->
</template>

Troubleshooting

Translations Not Updating

Problem: Changes to translations don't appear

Solution: Clear cache and refresh

const { refreshTranslations } = useT()
await refreshTranslations()

Missing Translation Indicator

Problem: Seeing [key.path] instead of translation

Solutions:

  1. Check if translation exists in locale file
  2. Verify key path is correct
  3. Check team override isn't blocking system translation
  4. Use dev mode to identify missing translations
<template>
  <CroutonI18nDevModeToggle />
  <!-- Click highlighted translations to add them -->
</template>

Wrong Language Displayed

Problem: Content shows in wrong language

Solutions:

  1. Check current locale: const { locale } = useI18n()
  2. Verify query includes locale parameter
  3. Check entity has translation for that locale
<script setup lang="ts">
const { locale } = useI18n()
console.log('Current locale:', locale.value)

// Ensure query includes locale
const { items } = await useCollectionQuery('products', {
  query: computed(() => ({ locale: locale.value }))
})
</script>