Add comprehensive multi-language support to your Nuxt Crouton applications with the @fyit/crouton-i18n addon package. This package provides team-specific translation overrides, development tools, and automatic locale management.
First, install the i18n package:
pnpm add @fyit/crouton-i18n
Then add it to your Nuxt config:
// nuxt.config.ts
export default defineNuxtConfig({
extends: [
'@fyit/crouton',
'@fyit/crouton-i18n'
]
})
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']
}
}
}
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>
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>
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: @fyit/crouton-i18nVersion: 0.1.0
Type: Nuxt Layer (Addon)
Dependencies: @nuxtjs/i18n v9.0.0
Crouton's i18n system merges translations from multiple sources. Understanding this merge order is essential for managing translations effectively.
Translations are loaded and merged in this order (last wins for conflicting keys):
1. @fyit/crouton/i18n/locales/ (Crouton component strings)
2. @fyit/crouton-auth/i18n/locales/ (Auth strings: signIn, register, etc.)
3. @fyit/crouton-admin/i18n/locales/ (Admin strings: superAdmin, etc.)
4. @fyit/crouton-i18n/i18n/locales/ (i18n admin UI strings)
5. layers/[domain]/i18n/locales/ (Domain-specific strings)
6. app/i18n/locales/ (App-level overrides)
7. Database (translationsUi table) (Runtime overrides)
| Package | Keys | Example |
|---|---|---|
| @fyit/crouton | Crouton component strings | crouton.table.search, crouton.form.save |
| @fyit/crouton-auth | Auth strings | auth.signIn, auth.register, teams.create |
| @fyit/crouton-admin | Admin strings | superAdmin.dashboard, superAdmin.users |
| @fyit/crouton-i18n | i18n admin UI | i18n.admin.addKey, i18n.admin.override |
| layers/domain | Domain-specific | bookings.form.location, bookings.status.confirmed |
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. @fyit/crouton/i18n/locales/en.json
b. @fyit/crouton-auth/i18n/locales/en.json
c. layers/bookings/i18n/locales/en.json
d. app/i18n/locales/en.json (if exists)
↓ (found in core)
4. Return value: "Save"
The @fyit/crouton-i18n package provides a CLI command to populate your database with translations from locale files.
crouton-generate seed-translations
translationsUi tableteamId: null (system default)isOverrideable: true (allows team overrides)# Seed all locales (via API, requires dev server running)
crouton-generate seed-translations
# Preview translations without seeding
crouton-generate seed-translations --dry-run
# Output SQL statements instead of using API
crouton-generate seed-translations --sql
# Seed from a specific layer only
crouton-generate seed-translations --layer=shop
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'
}
The recommended way to configure locales is via crouton.config.js. This flows through
to the i18n layer, the CLI generator, and the language switcher UI automatically:
// crouton.config.js
export default {
locales: ['en', 'de', 'es'], // ISO 639-1 codes — names auto-resolved
defaultLocale: 'en',
// Or with explicit names:
// locales: [
// { code: 'en', name: 'English' },
// { code: 'de', name: 'Deutsch' },
// { code: 'es', name: 'Español' }
// ],
features: { /* ... */ },
collections: [ /* ... */ ],
// ...
}
When no locales key is present, only ['en'] is configured by default. Add your desired locales explicitly.
You can also override locales directly in nuxt.config.ts (this takes precedence over crouton.config.js for the i18n module):
export default defineNuxtConfig({
i18n: {
locales: [
{ code: 'en', name: 'English', file: 'en.json' },
{ code: 'es', name: 'Español', file: 'es.json' },
{ code: 'de', name: 'Deutsch', file: 'de.json' }
]
}
})
i18n/locales/ folder structurenuxt.config.tsIf 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
langDir path is relative to the restructureDir (default: i18n). Use ./locales, NOT ./i18n/locales - the latter will result in a doubled path error.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
}
})
The package provides components with the CroutonI18n prefix for managing translations.
Display translated content with automatic fallback to English.
interface DisplayProps {
translations: Record<string, string> // Translation values by locale
languages?: string[] // Override available languages
}
<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>
Multi-language input component for forms supporting both single and multi-field translations.
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
showAiTranslate?: boolean // Enable AI translation suggestions
fieldType?: string // Field type context for AI (e.g., 'product_name', 'description')
collab?: CollabConnection // Collab connection for real-time block editing via Yjs
layout?: 'tabs' | 'side-by-side' // Layout mode (default: 'tabs')
primaryLocale?: string // Primary locale for side-by-side layout (default: 'en')
secondaryLocale?: string // Secondary locale for side-by-side layout
fieldOptions?: Record<string, FieldOptions> // Field-specific options like transforms
fieldGroups?: Record<string, string> // Group fields into collapsible sections (field name → group label)
defaultOpenGroups?: string[] // Which groups start open (default: all)
}
type SingleFieldValue = Record<string, string>
type MultiFieldValue = Record<string, Record<string, string>>
<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>
<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>
<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 textCroutonEditorSimple - Rich text editorSpecialized input component with built-in rich text editor support.
interface InputWithEditorProps {
modelValue: Record<string, string> // Translation values
fields: string[] // For backwards compatibility
label?: string
error?: string | boolean
useRichText?: boolean // Enable rich text editor
}
<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>
Dropdown selector for switching between available languages.
USelect with ghost variant<template>
<!-- Basic usage -->
<CroutonI18nLanguageSwitcher />
</template>
The component automatically:
Floating language switcher for overlay/island usage.
<template>
<!-- Floating switcher in top-right -->
<CroutonI18nLanguageSwitcherIsland />
</template>
Form component for managing UI translation overrides.
interface UiFormProps {
action: 'create' | 'update' | 'delete'
activeItem?: any // Item being edited
loading?: string // Loading state
collection: string // Collection name
}
<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>
table.search)table)List view for managing UI translations with Crouton table integration.
[
{ key: 'keyPath', label: 'Key Path' },
{ key: 'category', label: 'Category' },
{ key: 'values', label: 'Translations' },
{ key: 'description', label: 'Description' },
{ key: 'actions', label: 'Actions' }
]
<template>
<CroutonI18nUiList />
</template>
The component automatically:
useCollectionQuery('translationsUi')CroutonI18nDisplay componentBadge component showing single locale translation status.
interface CardsMiniProps {
locale: string // Locale code (en, nl, fr)
hasTranslation: boolean // Whether translation exists
}
<template>
<CroutonI18nCardsMini locale="en" :has-translation="true" />
<CroutonI18nCardsMini locale="nl" :has-translation="false" />
</template>
Container component showing translation status for all locales.
interface ListCardsProps {
item: any // Entity with translations
fields: string[] // Fields to check for translations
}
<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>
Development tool for managing missing translations with inline editing.
[key] patterns in DOM<template>
<!-- Add to your layout for dev mode -->
<CroutonI18nDevModeToggle />
</template>
[keyPath] patternsEnhanced translation composable with team override support and development mode.
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: () => string[]
getTranslationMeta: (key: string) => TranslationMeta
refreshTranslations: () => Promise<void>
locale: Ref<string>
isDev: boolean
devModeEnabled: Ref<boolean>
}
{key} replacement in strings[key] when not found<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>
<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>
<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>
Simple composable for translating entity fields based on current locale.
function useEntityTranslations(): {
t: (entity: any, field: string) => string
}
<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>
// 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')
The useTranslationsUi file exports named constants for the translationsUi collection configuration. These are not a composable function — import the individual exports directly.
import {
translationsUiSchema,
TRANSLATIONS_UI_COLUMNS,
TRANSLATIONS_UI_DEFAULTS,
TRANSLATIONS_UI_PAGINATION,
translationsUiConfig,
TRANSLATIONS_UI_COLLECTION
} from '#imports'
translationsUiSchema: Zod validation schema for translation recordsTRANSLATIONS_UI_COLUMNS: Table column definitions for CroutonTableTRANSLATIONS_UI_DEFAULTS: Default form values for new translationsTRANSLATIONS_UI_PAGINATION: Default pagination settingstranslationsUiConfig: Full collection configuration object (includes schema as non-enumerable property)TRANSLATIONS_UI_COLLECTION: Collection name constant ('translationsUi')<script setup lang="ts">
const state = ref({ ...TRANSLATIONS_UI_DEFAULTS })
const handleSubmit = async () => {
const valid = translationsUiSchema.parse(state.value)
await $fetch('/api/translations-ui', {
method: 'POST',
body: valid
})
}
</script>
<template>
<UForm :schema="translationsUiSchema" :state="state" @submit="handleSubmit">
<UFormField label="Key Path" name="keyPath">
<UInput v-model="state.keyPath" />
</UFormField>
<!-- ... -->
</UForm>
</template>
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(teamId, namespace, keyPath)
This ensures one translation per team per key path.
teamId = null, isOverrideable = trueteamId = <team-id>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>
<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>
<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>
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']"
/>
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
Use dot notation and categories:
// ✅ Good
'common.save'
'common.cancel'
'table.search'
'table.filter'
'dashboard.title'
// ❌ Bad
'save'
'cancelButton'
'SearchTable'
<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>
<!-- ✅ 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']"
/>
<template>
<!-- ✅ Good - Always include in dev layout -->
<CroutonI18nDevModeToggle />
<!-- Quickly identify and fix missing translations -->
</template>
Problem: Changes to translations don't appear
Solution: Clear cache and refresh
const { refreshTranslations } = useT()
await refreshTranslations()
Problem: Seeing [key.path] instead of translation
Solutions:
<template>
<CroutonI18nDevModeToggle />
<!-- Click highlighted translations to add them -->
</template>
Problem: Content shows in wrong language
Solutions:
const { locale } = useI18n()<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>