Skip to main content

I18n Best Practices

Follow these best practices to build scalable, maintainable multi-language applications.

Do's ✅

1. Use Dot Notation Keys

// ✅ Good - organized and nested
t('common.welcome')
t('pages.home.title')
t('ui.forms.email.label')

// ❌ Avoid - flat structure gets messy
t('welcome')
t('homeTitle')
t('emailLabel')

2. Type Your Language Enum

// ✅ Good - explicit type prevents typos
type Language = 'en' | 'es' | 'fr' | 'de';

// ❌ Avoid - string types allow any value
export const useTranslation = createI18n<any, string>({
// ...
});

3. Handle Loading States

// ✅ Good - check before rendering
const { t, isInitialLoading } = useTranslation();

if (isInitialLoading) {
return <LoadingSpinner />;
}

return <h1>{t('common.welcome')}</h1>;

// ❌ Avoid - showing raw keys on load
<h1>{t('common.welcome')}</h1> // May show "common.welcome"

4. Persist Language Preference

// ✅ Good - remember user choice
const handleLanguageChange = (newLan: Language) => {
updateTranslation(newLan);
localStorage.setItem('preferred-language', newLan);
};

// ❌ Avoid - reset on every page load
updateTranslation(browserLanguage); // User choice lost

5. Organize by Feature

// ✅ Good - grouped by feature
{
pages: {
home: { title: '...', description: '...' },
about: { title: '...', description: '...' },
},
ui: {
buttons: { save: '...', cancel: '...' },
forms: { email: { label: '...', error: '...' } },
},
}

// ❌ Avoid - flat unorganized structure
{
homePage: '...',
aboutPage: '...',
homeDescription: '...',
saveButton: '...',
cancelButton: '...',
}

6. Keep Translations in Sync

// ✅ Good - all languages have same keys
export const en = { common: { welcome: 'Welcome' } };
export const es = { common: { welcome: 'Bienvenido' } };
export const fr = { common: { welcome: 'Bienvenue' } };

// ❌ Avoid - missing keys in some languages
export const en = { common: { welcome: 'Welcome', bye: 'Goodbye' } };
export const es = { common: { welcome: 'Bienvenido' } }; // bye is missing!

7. Use Loading State in UI

// ✅ Good - disable during language switch
const { isUpdating } = useTranslation();

<select disabled={isUpdating}>
{/* Options */}
</select>

// ❌ Avoid - allow interaction during load
<select>
{/* Options */}
</select>

8. Catch Resource Errors

// ✅ Good - handle fetch errors
resource: async (lan) => {
try {
const res = await fetch(`/translations/${lan}.json`);
if (!res.ok) throw new Error('Failed to load');
return res.json();
} catch (error) {
console.error(`Failed to load ${lan}:`, error);
return defaultTranslations[lan];
}
}

// ❌ Avoid - let errors propagate
resource: async (lan) => {
const res = await fetch(`/translations/${lan}.json`);
return res.json(); // Will crash on error
}

Don'ts ❌

1. Don't Use Dynamic Keys

// ❌ Bad - loses type safety
const key = 'common.' + userInput;
t(key); // No autocomplete!

// ✅ Good - static keys with type checking
t('common.welcome');

2. Don't Forget Initial Loading State

// ❌ Bad - data might be null
const { data } = useTranslation();
console.log(data.common.welcome); // May crash!

// ✅ Good - check loading state first
const { t, isInitialLoading } = useTranslation();
if (isInitialLoading) return null;
console.log(t('common.welcome')); // Safe

3. Don't Ignore isUpdating State

// ❌ Bad - user can click during load
<button onClick={switchLanguage}>
Switch Language
</button>

// ✅ Good - disable during update
const { isUpdating } = useTranslation();
<button disabled={isUpdating} onClick={switchLanguage}>
Switch Language
</button>

4. Don't Store Translations in Component State

// ❌ Bad - duplicates state
const { t } = useTranslation();
const [cachedTranslations, setCached] = useState(t);

// ✅ Good - use hook directly
const { t } = useTranslation();
// Use t directly, no caching needed

5. Don't Mix Multiple i18n Instances

// ❌ Bad - confusing and wasteful
const useTranslation1 = createI18n({...});
const useTranslation2 = createI18n({...});

// ✅ Good - single instance per app
export const useTranslation = createI18n({...});
// Use everywhere

6. Don't Leave Keys Untranslated

// ❌ Bad - missing Spanish translations
export const es = {
common: {
welcome: 'Bienvenido',
// goodbye is missing!
},
};

// ✅ Good - complete translations
export const es = {
common: {
welcome: 'Bienvenido',
goodbye: 'Adiós',
},
};

7. Don't Hardcode Language Selection

// ❌ Bad - user preference ignored
<select value="en" disabled>
<option>English</option>
</select>

// ✅ Good - allow user to switch
const { lan, updateTranslation } = useTranslation();
<select value={lan} onChange={(e) => updateTranslation(e.target.value)}>
<option value="en">English</option>
<option value="es">Español</option>
</select>

8. Don't Trust Untranslated Content

// ❌ Bad - translation might be missing
<h1>{t('pages.unknownPage.title') || 'Default Title'}</h1>

// ✅ Good - ensure key exists at build time
<h1>{t('pages.home.title')}</h1> // TypeScript validates

Common Patterns

Pattern 1: Context-Based Translations

Use Context to avoid prop drilling:

import { createContext, useContext } from 'react';
import { useTranslation } from './i18n';

const I18nContext = createContext<ReturnType<typeof useTranslation> | null>(null);

export function I18nProvider({ children }: { children: React.ReactNode }) {
const translation = useTranslation();
return (
<I18nContext.Provider value={translation}>
{children}
</I18nContext.Provider>
);
}

export function useI18n() {
const context = useContext(I18nContext);
if (!context) throw new Error('useI18n must be used within I18nProvider');
return context;
}

Pattern 2: SEO-Friendly Routes

// Implement language in URL path for better SEO
// /en/about, /es/about, /fr/about

export default function Layout({ params }: { params: { lang: string } }) {
return (
<html lang={params.lang}>
<body>{/* content */}</body>
</html>
);
}

Pattern 3: Memoized Switcher

import { memo } from 'react';

const LanguageSwitcher = memo(function LanguageSwitcher() {
const { lan, updateTranslation, isUpdating } = useTranslation();

return (
<select
value={lan}
onChange={(e) => updateTranslation(e.target.value)}
disabled={isUpdating}
>
{/* Options */}
</select>
);
});

export default LanguageSwitcher;

Pattern 4: Cached Loading

const translationCache = new Map<string, any>();

export const useTranslation = createI18n({
initialLan: 'en',
resource: async (lan) => {
if (translationCache.has(lan)) {
return translationCache.get(lan);
}

const data = await fetch(`/api/i18n/${lan}`).then(r => r.json());
translationCache.set(lan, data);
return data;
},
});

Testing

Unit Test Example

import { createI18n } from 'zustic/i18n';

describe('i18n', () => {
const mockTranslations = {
en: { greeting: 'Hello' },
es: { greeting: 'Hola' },
};

const useTestTranslation = createI18n({
initialLan: 'en',
resource: (lan) => mockTranslations[lan],
});

test('returns correct translation', async () => {
const { t } = useTestTranslation();
expect(t('greeting')).toBe('Hello');
});

test('switches language', async () => {
const { t, updateTranslation } = useTestTranslation();

updateTranslation('es');
// Wait for update
await new Promise(resolve => setTimeout(resolve, 100));

expect(t('greeting')).toBe('Hola');
});
});

React Component Test

import { render, screen } from '@testing-library/react';
import { TranslationProvider } from './TranslationProvider';
import { Header } from './Header';

test('renders translated header', () => {
render(
<TranslationProvider>
<Header />
</TranslationProvider>
);

expect(screen.getByText('Welcome')).toBeInTheDocument();
});

Comparison: i18n vs Alternatives

FeatureZustic i18ni18nextreact-i18next
Size~2KB~30KB~8KB
Type Safety✅ Full⚠️ Partial⚠️ Partial
Setup⭐ Very Simple⭐⭐⭐⭐ Complex⭐⭐⭐ Medium
Learning⭐ Easy⭐⭐⭐⭐ Steep⭐⭐⭐ Medium
Bundle⭐⭐⭐⭐⭐ Tiny⭐⭐ Large⭐⭐⭐ Medium
Async✅ Yes✅ Yes✅ Yes
React✅ Native⚠️ Via Plugin✅ Native

Troubleshooting

Issue: Keys showing raw instead of translations

Problem: Seeing "common.welcome" instead of the actual translation

Solution: Wait for isInitialLoading to be false:

const { t, isInitialLoading } = useTranslation();

if (isInitialLoading) return <Spinner />;

return <h1>{t('common.welcome')}</h1>; // Now safe

Issue: Language not switching

Problem: updateTranslation doesn't change language

Solution: Ensure language type matches:

type Language = 'en' | 'es' | 'fr';

// ✅ Correct casting
updateTranslation(e.target.value as Language);

// ❌ Wrong - type mismatch
updateTranslation(e.target.value);

Issue: Slow translation loading

Problem: First language load takes too long

Solution: Implement caching:

const cache = new Map();

resource: async (lan) => {
if (cache.has(lan)) return cache.get(lan);

const data = await fetch(`/api/translations/${lan}`).then(r => r.json());
cache.set(lan, data);
return data;
}

Issue: Race condition on language switch

Problem: Switching languages rapidly causes stale translations

Solution: The library handles this with request IDs:

// This is automatic - last request wins
updateTranslation('es');
updateTranslation('fr');
updateTranslation('de');

// Result will be German, even if Spanish finishes last

Checklist Before Production

  • ✅ All translation keys are type-safe
  • ✅ Loading states are handled
  • ✅ Language preferences are persisted
  • ✅ All languages have complete translations
  • ✅ Error handling is in place
  • ✅ Performance is optimized (caching, lazy loading)
  • ✅ Component tests pass
  • ✅ i18n works in SSR if applicable
  • ✅ Metadata/SEO is translated
  • ✅ RTL languages are supported (if needed)

Next Steps