i18n & Localization
Internationalization (i18n) and Localization (L10n) best practices.
1. Core Concepts
| Term | Meaning |
|---|---|
| i18n | Internationalization - making app translatable |
| L10n | Localization - actual translations |
| Locale | Language + Region (en-US, tr-TR) |
| RTL | Right-to-left languages (Arabic, Hebrew) |
2. When to Use i18n
| Project Type | i18n Needed? |
|---|---|
| Public web app | ✅ Yes |
| SaaS product | ✅ Yes |
| Internal tool | ⚠️ Maybe |
| Single-region app | ⚠️ Consider future |
| Personal project | ❌ Optional |
3. Implementation Patterns
Paraglide JS (Recommended for this project)
tsx
import { m } from '@/paraglide/messages'
import { getLocale, setLocale, locales } from '@/paraglide/runtime'
// Simple message
function Welcome() {
return <h1>{m.welcome_title()}</h1>
}
// Message with parameters
function Greeting() {
return <p>{m.example_message({ username: 'Alice' })}</p>
}
// Locale switching
function LocaleSwitcher() {
const currentLocale = getLocale()
return (
<select value={currentLocale} onChange={(e) => setLocale(e.target.value)}>
{locales.map(locale => (
<option key={locale} value={locale}>{locale.toUpperCase()}</option>
))}
</select>
)
}
React (react-i18next)
tsx
import { useTranslation } from 'react-i18next';
function Welcome() {
const { t } = useTranslation();
return <h1>{t('welcome.title')}</h1>;
}
Next.js (next-intl)
tsx
import { useTranslations } from 'next-intl';
export default function Page() {
const t = useTranslations('Home');
return <h1>{t('title')}</h1>;
}
Python (gettext)
python
from gettext import gettext as _
print(_("Welcome to our app"))
4. File Structure
Paraglide JS (This Project)
code
project.inlang/ ├── settings.json # Inlang config messages/ ├── en.json # English translations └── no.json # Norwegian translations src/paraglide/ # Auto-generated by Vite plugin ├── messages.ts # Type-safe message functions └── runtime.ts # getLocale, setLocale, locales
Traditional Structure (Other Projects)
code
locales/ ├── en/ │ ├── common.json │ ├── auth.json │ └── errors.json ├── no/ │ ├── common.json │ ├── auth.json │ └── errors.json
5. Paraglide JS Deep Dive
Setup & Configuration
Inlang Project Settings (project.inlang/settings.json):
json
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en", "no"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
}
Vite Plugin Configuration (vite.config.ts):
tsx
import { paraglideVitePlugin } from '@inlang/paraglide-js-adapter-vite'
export default defineConfig({
plugins: [
paraglideVitePlugin({
project: './project.inlang',
outdir: './src/paraglide',
strategy: ['url'], // URL-based locale switching
}),
],
})
Message Files Format
Messages are stored as flat JSON objects with dot-notation keys:
messages/en.json:
json
{
"welcome_title": "Welcome",
"example_message": "Hello {username}!",
"current_locale": "Current locale: {locale}",
"nav_home": "Home",
"nav_about": "About us"
}
messages/no.json:
json
{
"welcome_title": "Velkommen",
"example_message": "Hallo {username}!",
"current_locale": "Gjeldende språk: {locale}",
"nav_home": "Hjem",
"nav_about": "Om oss"
}
Using Messages in Components
Simple messages (no parameters):
tsx
import { m } from '@/paraglide/messages'
function Header() {
return <h1>{m.welcome_title()}</h1>
}
Messages with parameters:
tsx
function Greeting({ username }) {
return <p>{m.example_message({ username })}</p>
}
Locale switching:
tsx
import { getLocale, setLocale, locales } from '@/paraglide/runtime'
function LocaleSwitcher() {
const currentLocale = getLocale()
return (
<div>
<span>{m.current_locale({ locale: currentLocale })}</span>
<select value={currentLocale} onChange={(e) => setLocale(e.target.value)}>
{locales.map(locale => (
<option key={locale} value={locale}>{locale.toUpperCase()}</option>
))}
</select>
</div>
)
}
With TanStack Router
Paraglide JS integrates seamlessly with TanStack Router for URL-based locale switching:
tsx
import { createFileRoute } from '@tanstack/react-router'
import { m } from '@/paraglide/messages'
import { getLocale, setLocale } from '@/paraglide/runtime'
export const Route = createFileRoute('/demo/i18n')({
component: DemoPage,
})
function DemoPage() {
return (
<div>
<h1>{m.welcome_title()}</h1>
<p>{m.example_message({ username: 'TanStack Router' })}</p>
<LocaleSwitcher />
</div>
)
}
Key Advantages
- •✅ Type-safe: Auto-generated TypeScript functions
- •✅ Zero runtime overhead: Compiled at build time
- •✅ IDE support: Full autocomplete for message keys
- •✅ No hooks needed: Simple function calls
- •✅ Automatic locale detection: URL-based switching
- •✅ Inlang ecosystem: Integrates with translation management tools
6. Best Practices
DO ✅
- •Use translation keys, not raw text
- •Namespace translations by feature (use dot notation:
nav.home,errors.not_found) - •Support pluralization with ICU format
- •Handle date/number formats per locale using
IntlAPI - •Use Paraglide's
m()function for all user-facing strings - •Keep message keys descriptive and consistent
- •Test locale switching in development
- •Use
getLocale()to access current locale when needed
DON'T ❌
- •Hardcode strings in components
- •Concatenate translated strings (use parameters instead)
- •Assume text length (German is 30% longer, Chinese is shorter)
- •Mix languages in same file
- •Use
m()in non-component contexts without proper setup - •Forget to update all locale files when adding new messages
7. Common Issues
| Issue | Solution |
|---|---|
| Missing translation | Fallback to default language (baseLocale) |
| Hardcoded strings | Use linter/checker script (see Script section) |
| Date format | Use Intl.DateTimeFormat per locale |
| Number format | Use Intl.NumberFormat per locale |
| Pluralization | Use ICU message format in JSON |
m() not found | Ensure Vite plugin is configured and src/paraglide/ is generated |
| Locale not switching | Check setLocale() is called and router is configured |
Type errors on m. | Run Vite dev server to regenerate src/paraglide/messages.ts |
| Missing keys in locale | Run i18n_checker.py to find mismatches |
8. RTL Support
css
/* CSS Logical Properties */
.container {
margin-inline-start: 1rem; /* Not margin-left */
padding-inline-end: 1rem; /* Not padding-right */
}
[dir="rtl"] .icon {
transform: scaleX(-1);
}
9. Checklist
Before shipping:
- • All user-facing strings use
m.key()function calls - • Locale files exist for all supported languages (en, no, etc.)
- • Date/number formatting uses
IntlAPI - • RTL layout tested (if applicable)
- • Base locale configured in
project.inlang/settings.json - • No hardcoded strings in components
- • Vite plugin generates
src/paraglide/correctly - • Locale switching works with
setLocale() - • i18n_checker.py passes with no critical issues
- • All message keys have translations in all locales
Script
| Script | Purpose | Command |
|---|---|---|
scripts/i18n_checker.py | Detect hardcoded strings & missing translations | python scripts/i18n_checker.py <project_path> |