DeesseJS Collections

i18n Concepts

Understanding internal vs external internationalization

i18n Concepts

Collections provides native internationalization support, but it's important to understand the distinction between internal i18n and external i18n.

Overview

Prop

Type

Internal i18n

Internal i18n refers to translating the structural elements of your collections - things that define how your data layer works.

What Gets Translated

How Internal i18n Works

  1. Define supported locales in your configuration
  2. Provide translations for labels, descriptions, messages
  3. Set current locale when performing operations
  4. Collections returns translated values based on locale
// Configuration
const { collections } = defineConfig({
  i18n: {
    defaultLocale: 'en',
    locales: ['en', 'fr', 'es'],
    fallbackLocale: 'en'
  }
})

// Set locale for operation
const label = collections.posts.fields.title.getLabel('fr')
// Returns: 'Titre'

External i18n

External i18n refers to translating the actual content that users create and store in your database.

What Gets Translated

Approaches to External i18n

1. Separate Records per Locale

Store each translation as a separate record with a locale field:

export const posts = collection({
  slug: 'posts',
  fields: {
    title: field({ type: text() }),
    content: field({ type: text() }),
    locale: field({ type: enumField(['en', 'fr', 'es']) })
  }
})

// Usage
const englishPost = await collections.posts.create({
  data: {
    title: 'Hello World',
    content: '...',
    locale: 'en'
  }
})

const frenchPost = await collections.posts.create({
  data: {
    title: 'Bonjour le monde',
    content: '...',
    locale: 'fr'
  }
})

Pros:

  • Simple to implement
  • Easy to query by locale
  • Works well with Collections

Cons:

  • Duplicate data structure
  • Need to manually link translations

2. Translation Relations

Use relations to link translations:

export const posts = collection({
  slug: 'posts',
  fields: {
    title: field({ type: text() }),
    content: field({ type: text() }),
    locale: field({ type: enumField(['en', 'fr', 'es']) }),
    translationGroup: field({ type: text() }) // ID to group translations
  }
})

// Query all translations
const translations = await collections.posts.findMany({
  where: {
    translationGroup: { equals: 'group-123' }
  }
})

Pros:

  • Links related translations
  • Can query all translations at once

Cons:

  • Requires manual management of translation groups
  • More complex queries

3. JSON Fields for Translations

Store all translations in a single JSON field:

export const posts = collection({
  slug: 'posts',
  fields: {
    title: field({
      type: json(z.object({
        en: z.string(),
        fr: z.string().optional(),
        es: z.string().optional()
      }))
    }),
    content: field({
      type: json(z.object({
        en: z.string(),
        fr: z.string().optional(),
        es: z.string().optional()
      }))
    })
  }
})

// Usage
await collections.posts.create({
  data: {
    title: {
      en: 'Hello World',
      fr: 'Bonjour le monde',
      es: 'Hola mundo'
    },
    content: {
      en: 'Content in English...',
      fr: 'Contenu en français...'
    }
  }
})

Pros:

  • Single record per content piece
  • Easy to access all translations

Cons:

  • Harder to query individual translations
  • Schema is more rigid
  • Can't easily query missing translations

4. Separate Translation Collection

Create a dedicated collection for translations:

export const posts = collection({
  slug: 'posts',
  fields: {
    slug: field({ type: text(), unique: true })
  }
})

export const postTranslations = collection({
  slug: 'postTranslations',
  fields: {
    postId: field({ type: relation('posts') }),
    locale: field({ type: enumField(['en', 'fr', 'es']) }),
    title: field({ type: text() }),
    content: field({ type: text() })
  }
})

// Query with translation
const postWithTranslation = await collections.posts.findUnique({
  where: { id: 1 },
  include: {
    postTranslations: {
      where: { locale: { equals: 'fr' } }
    }
  }
})

Pros:

  • Clean separation of content
  • Flexible schema
  • Easy to query

Cons:

  • More complex queries
  • Requires joins

Choosing an Approach

Prop

Type

Best Practices

Internal i18n

  1. Always provide translations for all supported locales
  2. Use fallback locale for missing translations
  3. Keep translations consistent across your schema
  4. Test all locales during development
// ✅ Good - complete translations
label: {
  en: 'Title',
  fr: 'Titre',
  es: 'Título'
}

// ❌ Bad - incomplete translations
label: {
  en: 'Title',
  fr: 'Titre'
  // Missing 'es'
}

External i18n

  1. Choose approach based on use case
  2. Handle missing translations gracefully
  3. Consider content management workflow
  4. Plan for content synchronization
// ✅ Good - handle missing translations
const getPost = async (slug: string, locale: string) => {
  let post = await collections.posts.findFirst({
    where: {
      slug: { equals: slug },
      locale: { equals: locale }
    }
  })

  // Fallback to default locale
  if (!post && locale !== 'en') {
    post = await collections.posts.findFirst({
      where: {
        slug: { equals: slug },
        locale: { equals: 'en' }
      }
    })
  }

  return post
}

Example: Complete i18n Setup

Here's a complete example showing both internal and external i18n:

// config/database.ts
import { defineConfig } from '@deessejs/collections'

export const { collections } = defineConfig({
  i18n: {
    defaultLocale: 'en',
    locales: ['en', 'fr', 'es'],
    fallbackLocale: 'en'
  },

  collections: [posts, users]
})

// collections/posts.ts
import { collection, field } from '@deessejs/collections'
import { text, enumField } from '@deessejs/collections/fields'

export const posts = collection({
  slug: 'posts',

  // Internal i18n - collection labels
  label: {
    en: 'Blog Post',
    fr: 'Article de Blog',
    es: 'Artículo de Blog'
  },

  fields: {
    // Internal i18n - field labels
    title: field({
      type: text(),
      label: {
        en: 'Title',
        fr: 'Titre',
        es: 'Título'
      }
    }),

    // Internal i18n - field descriptions
    content: field({
      type: text(),
      description: {
        en: 'The main content of the post',
        fr: 'Le contenu principal de l\'article'
      }
    }),

    // External i18n - locale field for content
    locale: field({
      type: enumField(['en', 'fr', 'es']),
      label: {
        en: 'Language',
        fr: 'Langue'
      }
    })
  }
})

// Usage
const englishPost = await collections.posts.create({
  data: {
    title: 'Hello World',      // External: English content
    content: 'English content...',
    locale: 'en'
  },
  locale: 'en' // Internal: get error messages in English
})

const frenchPost = await collections.posts.create({
  data: {
    title: 'Bonjour le monde', // External: French content
    content: 'Contenu français...',
    locale: 'fr'
  },
  locale: 'fr' // Internal: get error messages in French
})

Summary

  • Internal i18n = Translations of UI elements managed by Collections
  • External i18n = Translation of user content managed by your application
  • Use the approach that fits your use case
  • Plan your i18n strategy before building

Next Steps

  • i18n - Internal i18n configuration and usage
  • Fields - Field-level i18n options
  • Core Concepts - Configuration and setup

On this page