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:
- URL path (
/es/submit) Accept-Languageheader- Cookie (
NEXT_LOCALE) - 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¶
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"
SEO and Alternate Links¶
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¶
- Developer adds English string to
messages/en.json - Developer pushes to
main - CI extracts new keys and sends to translation service (Phrase, Lokalise)
- Translator translates new keys
- CI pulls translations and commits to repo
- Deploy includes updated translations
What's Next¶
- System Overview — How i18n middleware works
- Database Schema — JSONB multilingual columns
- User Guide — User-facing i18n features