Skip to content

Internationalization (i18n)

!!! info "TL;DR" next-intl for UI strings. JSONB for multilingual database content (category names, tags). 8 languages in 3 phases. Locale routing via [locale] dynamic segment.

Supported Languages

Phase Languages Status
Phase 1 English (en) Launch
Phase 2 Spanish (es), French (fr), German (de), Russian (ru) Post-launch
Phase 3 Portuguese (pt-BR), Japanese (ja), Italian (it) Growth

Locale Routing

Routes are prefixed with locale using a dynamic segment:

/                      → Redirects to /en
/en                    → English homepage
/es                    → Spanish homepage
/en/submit             → English submission form
/es/submit             → Spanish submission form

Middleware Configuration

Middleware detects locale from:

  1. URL path (/es/submit)
  2. Accept-Language header
  3. Cookie (NEXT_LOCALE)
  4. Default locale (en)
// middleware.ts
import createIntlMiddleware from 'next-intl/middleware'

const intlMiddleware = createIntlMiddleware({
  locales: ['en', 'es', 'fr', 'de', 'ru', 'pt-BR', 'ja', 'it'],
  defaultLocale: 'en',
  localePrefix: 'always', // Always include locale in URL
})

export async function middleware(request: NextRequest) {
  return intlMiddleware(request)
}

Locale Switcher Component

// components/layout/locale-switcher.tsx
'use client'

import { useLocale } from 'next-intl'
import { useRouter } from 'next/navigation'

export function LocaleSwitcher() {
  const locale = useLocale()
  const router = useRouter()

  const changeLocale = (newLocale: string) => {
    const path = window.location.pathname
    const newPath = path.replace(`/${locale}`, `/${newLocale}`)
    router.push(newPath)
  }

  return (
    <select value={locale} onChange={(e) => changeLocale(e.target.value)}>
      <option value="en">English</option>
      <option value="es">Español</option>
      <option value="fr">Français</option>
      <option value="de">Deutsch</option>
      <option value="ru">Русский</option>
      <option value="pt-BR">Português (BR)</option>
      <option value="ja">日本語</option>
      <option value="it">Italiano</option>
    </select>
  )
}

Translation Files

UI strings are stored in JSON files under messages/:

messages/
├── en.json
├── es.json
├── fr.json
├── de.json
├── ru.json
├── pt-BR.json
├── ja.json
└── it.json

Translation File Structure

{
  "common": {
    "submit": "Submit",
    "cancel": "Cancel",
    "search": "Search"
  },
  "submissions": {
    "title": "Feedback",
    "create": "Submit Feedback",
    "status": {
      "open": "Open",
      "planned": "Planned",
      "shipped": "Shipped"
    },
    "type": {
      "idea": "Idea",
      "problem": "Problem"
    }
  },
  "votes": {
    "vote": "Vote",
    "voted": "Voted"
  },
  "comments": {
    "title": "Comments",
    "add": "Add Comment",
    "official": "Official Response"
  }
}

Using Translations

Server Components:

import { getTranslations } from 'next-intl/server'

export default async function SubmissionsPage() {
  const t = await getTranslations('submissions')

  return <h1>{t('title')}</h1>
}

Client Components:

'use client'

import { useTranslations } from 'next-intl'

export function SubmitButton() {
  const t = useTranslations('common')

  return <button>{t('submit')}</button>
}

Parameterized Translations

{
  "submissions": {
    "vote_count": "{count, plural, =0 {No votes} =1 {1 vote} other {# votes}}"
  }
}

Usage:

const t = useTranslations('submissions')

<p>{t('vote_count', { count: 42 })}</p>
// Output: "42 votes"

Database Content Translations

Category and tag names are stored as JSONB for multilingual support:

Categories Table

CREATE TABLE categories (
  id UUID PRIMARY KEY,
  name JSONB NOT NULL, -- {"en": "Feature Requests", "es": "Solicitudes de funciones"}
  slug TEXT NOT NULL UNIQUE,
  color TEXT NOT NULL
);

Fetching Localized Names

Server-side helper:

// lib/i18n/localize.ts
import { getLocale } from 'next-intl/server'

export async function localizeName(nameJson: Record<string, string>) {
  const locale = await getLocale()
  return nameJson[locale] || nameJson['en'] // Fallback to English
}

Usage:

const { data: categories } = await supabase
  .from('categories')
  .select('id, name, slug')

const localizedCategories = await Promise.all(
  categories.map(async (c) => ({
    ...c,
    name: await localizeName(c.name),
  }))
)

Seeding Multilingual Data

INSERT INTO categories (name, slug, color) VALUES
(
  '{"en": "Feature Requests", "es": "Solicitudes de funciones", "fr": "Demandes de fonctionnalités", "de": "Funktionsanfragen"}',
  'feature-requests',
  'blue'
);

Email Notifications

Transactional emails use locale-specific templates:

lib/email-templates/
├── en/
│   ├── feedback-update.tsx
│   └── weekly-digest.tsx
├── es/
│   ├── feedback-update.tsx
│   └── weekly-digest.tsx
└── ...

Load template based on user's locale preference:

// lib/email.ts
import { Resend } from 'resend'

export async function sendFeedbackUpdate(userId: string, submissionId: string) {
  const { locale } = await getUserPreferences(userId)

  const template = await import(
    `@/lib/email-templates/${locale}/feedback-update`
  )

  const resend = new Resend(process.env.RESEND_API_KEY)

  await resend.emails.send({
    from: 'Canviq <notifications@canviq.app>',
    to: userEmail,
    subject: template.subject,
    react: template.default({ submissionId }),
  })
}

Date and Number Formatting

Use Intl APIs for locale-aware formatting:

Dates

import { useLocale } from 'next-intl'

export function FormattedDate({ date }: { date: Date }) {
  const locale = useLocale()

  const formatted = new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  }).format(date)

  return <time dateTime={date.toISOString()}>{formatted}</time>
}

Numbers

const locale = useLocale()

const voteCount = 1234
const formatted = new Intl.NumberFormat(locale).format(voteCount)
// en: "1,234"
// de: "1.234"
// fr: "1 234"

Include <link rel="alternate"> tags for all locales:

// app/[locale]/layout.tsx
export async function generateMetadata({ params }) {
  const locale = params.locale

  return {
    alternates: {
      languages: {
        en: 'https://canviq.app/en',
        es: 'https://canviq.app/es',
        fr: 'https://canviq.app/fr',
        de: 'https://canviq.app/de',
        ru: 'https://canviq.app/ru',
        'pt-BR': 'https://canviq.app/pt-BR',
        ja: 'https://canviq.app/ja',
        it: 'https://canviq.app/it',
      },
    },
  }
}

Missing Translations

If a key is missing in a locale, next-intl falls back to English:

// messages/es.json (missing "submissions.create")
// Falls back to messages/en.json

const t = useTranslations('submissions')
t('create') // "Submit Feedback" (from en.json)

During development, enable warnings for missing translations:

// i18n.config.ts
export default {
  onError: (error) => {
    console.warn('Translation error:', error)
  },
}

Translation Workflow

  1. Developer adds English string to messages/en.json
  2. Developer pushes to main
  3. CI extracts new keys and sends to translation service (Phrase, Lokalise)
  4. Translator translates new keys
  5. CI pulls translations and commits to repo
  6. Deploy includes updated translations

What's Next