Internationalization (i18n)

This boilerplate uses i18next for internationalization, providing a robust solution for multi-language support with TypeScript type safety.

Setup

The i18n configuration is already set up in the boilerplate:

src/i18n/index.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import * as Localization from 'expo-localization';
import en from './locales/en.json';
import pt from './locales/pt.json';
import es from './locales/es.json';

const resources = {
  en: { translation: en },
  pt: { translation: pt },
  es: { translation: es },
};

i18n
  .use(initReactI18next)
  .init({
    resources,
    lng: Localization.locale.split('-')[0], // Get device language
    fallbackLng: 'en',
    interpolation: {
      escapeValue: false, // React already escapes
    },
  });

export default i18n;

Translation Files

Create JSON files for each language:

src/i18n/locales/en.json
{
  "common": {
    "welcome": "Welcome",
    "loading": "Loading...",
    "error": "Something went wrong",
    "save": "Save",
    "cancel": "Cancel",
    "delete": "Delete",
    "edit": "Edit"
  },
  "auth": {
    "login": "Login",
    "logout": "Logout",
    "email": "Email",
    "password": "Password",
    "forgotPassword": "Forgot password?",
    "noAccount": "Don't have an account?",
    "signUp": "Sign up",
    "invalidCredentials": "Invalid email or password"
  },
  "home": {
    "title": "Home",
    "greeting": "Hello, {{name}}!",
    "taskCount": "You have {{count}} tasks",
    "taskCount_plural": "You have {{count}} tasks"
  },
  "tasks": {
    "title": "Tasks",
    "newTask": "New Task",
    "completed": "Completed",
    "pending": "Pending",
    "noTasks": "No tasks yet",
    "createFirst": "Create your first task"
  }
}
src/i18n/locales/pt.json
{
  "common": {
    "welcome": "Bem-vindo",
    "loading": "Carregando...",
    "error": "Algo deu errado",
    "save": "Salvar",
    "cancel": "Cancelar",
    "delete": "Excluir",
    "edit": "Editar"
  },
  "auth": {
    "login": "Entrar",
    "logout": "Sair",
    "email": "E-mail",
    "password": "Senha",
    "forgotPassword": "Esqueceu a senha?",
    "noAccount": "Não tem uma conta?",
    "signUp": "Cadastre-se",
    "invalidCredentials": "E-mail ou senha inválidos"
  },
  "home": {
    "title": "Início",
    "greeting": "Olá, {{name}}!",
    "taskCount": "Você tem {{count}} tarefa",
    "taskCount_plural": "Você tem {{count}} tarefas"
  },
  "tasks": {
    "title": "Tarefas",
    "newTask": "Nova Tarefa",
    "completed": "Concluídas",
    "pending": "Pendentes",
    "noTasks": "Nenhuma tarefa ainda",
    "createFirst": "Crie sua primeira tarefa"
  }
}

Basic Usage

Use the useTranslation hook in your components:

typescript
import { View, Text } from 'react-native';
import { useTranslation } from 'react-i18next';

export function WelcomeScreen() {
  const { t } = useTranslation();

  return (
    <View>
      <Text>{t('common.welcome')}</Text>
      <Text>{t('home.greeting', { name: 'John' })}</Text>
    </View>
  );
}

In ViewModels

Use translations in your ViewModels for validation messages, etc:

typescript
import { useTranslation } from 'react-i18next';

export function useLoginViewModel() {
  const { t } = useTranslation();

  const form = useForm({
    resolver: zodResolver(loginSchema),
  });

  const loginMutation = useMutation({
    mutationFn: authApi.login,
    onError: (error) => {
      form.setError('root', {
        message: t('auth.invalidCredentials'),
      });
    },
  });

  return {
    form,
    errorMessage: form.formState.errors.root?.message,
    submitButtonText: t('auth.login'),
    forgotPasswordText: t('auth.forgotPassword'),
  };
}

Interpolation

Pass variables to translations:

typescript
// Translation
{
  "greeting": "Hello, {{name}}!",
  "welcome": "Welcome back, {{name}}. You have {{count}} new messages."
}

// Usage
const { t } = useTranslation();

<Text>{t('greeting', { name: 'John' })}</Text>
<Text>{t('welcome', { name: 'John', count: 5 })}</Text>

Pluralization

Handle singular and plural forms automatically:

json
{
  "taskCount": "You have {{count}} task",
  "taskCount_plural": "You have {{count}} tasks"
}
typescript
const { t } = useTranslation();

// Automatically picks the right form based on count
<Text>{t('taskCount', { count: 0 })}</Text>  // "You have 0 tasks"
<Text>{t('taskCount', { count: 1 })}</Text>  // "You have 1 task"
<Text>{t('taskCount', { count: 5 })}</Text>  // "You have 5 tasks"

Changing Language

Create a language selector:

typescript
import { useTranslation } from 'react-i18next';
import { Pressable, Text, View } from 'react-native';

export function LanguageSelector() {
  const { i18n, t } = useTranslation();
  const currentLanguage = i18n.language;

  const languages = [
    { code: 'en', name: 'English' },
    { code: 'pt', name: 'Português' },
    { code: 'es', name: 'Español' },
  ];

  const handleChangeLanguage = (languageCode: string) => {
    i18n.changeLanguage(languageCode);
  };

  return (
    <View className="gap-2">
      {languages.map((lang) => (
        <Pressable
          key={lang.code}
          onPress={() => handleChangeLanguage(lang.code)}
          className={cn(
            'p-4 rounded-lg',
            currentLanguage === lang.code
              ? 'bg-blue-500'
              : 'bg-slate-200 dark:bg-slate-700'
          )}
        >
          <Text
            className={cn(
              'font-semibold',
              currentLanguage === lang.code
                ? 'text-white'
                : 'text-slate-900 dark:text-white'
            )}
          >
            {lang.name}
          </Text>
        </Pressable>
      ))}
    </View>
  );
}

Persist Language Preference

Save the user's language choice:

src/stores/settings.store.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { storage } from '@/data/storage/mmkv';
import i18n from '@/i18n';

interface SettingsState {
  language: string;
  setLanguage: (language: string) => void;
}

export const useSettingsStore = create<SettingsState>()(
  persist(
    (set) => ({
      language: 'en',
      
      setLanguage: (language) => {
        i18n.changeLanguage(language);
        set({ language });
      },
    }),
    {
      name: 'settings-storage',
      storage: createJSONStorage(() => ({
        getItem: (name) => storage.getString(name) ?? null,
        setItem: (name, value) => storage.set(name, value),
        removeItem: (name) => storage.delete(name),
      })),
    }
  )
);
typescript
// In your app initialization
import { useSettingsStore } from '@/stores/settings.store';
import i18n from '@/i18n';

export default function App() {
  const language = useSettingsStore(state => state.language);

  useEffect(() => {
    i18n.changeLanguage(language);
  }, [language]);

  return <YourApp />;
}

Namespace Organization

Organize translations by features:

Flat structure (default)
{
  "common.welcome": "Welcome",
  "auth.login": "Login",
  "tasks.title": "Tasks"
}
Nested structure
{
  "common": {
    "welcome": "Welcome",
    "actions": {
      "save": "Save",
      "cancel": "Cancel"
    }
  },
  "auth": {
    "login": "Login",
    "form": {
      "email": "Email",
      "password": "Password"
    }
  }
}
Use nested structure for better organization in large apps.

TypeScript Type Safety

Add type safety to your translations:

src/i18n/types.ts
import en from './locales/en.json';

export type TranslationKeys = typeof en;

declare module 'react-i18next' {
  interface CustomTypeOptions {
    defaultNS: 'translation';
    resources: {
      translation: TranslationKeys;
    };
  }
}

Now you get autocomplete and type checking:

typescript
const { t } = useTranslation();

// ✅ TypeScript knows this key exists
t('auth.login');

// ❌ TypeScript error: key doesn't exist
t('auth.invalidKey');

Date & Time Formatting

Format dates according to locale:

typescript
import { format } from 'date-fns';
import { enUS, ptBR, es } from 'date-fns/locale';
import { useTranslation } from 'react-i18next';

const locales = {
  en: enUS,
  pt: ptBR,
  es,
};

export function useDateFormatter() {
  const { i18n } = useTranslation();
  const locale = locales[i18n.language as keyof typeof locales];

  const formatDate = (date: Date, pattern: string = 'PP') => {
    return format(date, pattern, { locale });
  };

  return { formatDate };
}

// Usage
function TaskItem({ task }) {
  const { formatDate } = useDateFormatter();

  return (
    <Text>{formatDate(task.createdAt, 'PPpp')}</Text>
  );
}

Currency Formatting

typescript
import { useTranslation } from 'react-i18next';

const currencyMap = {
  en: 'USD',
  pt: 'BRL',
  es: 'EUR',
};

export function useCurrencyFormatter() {
  const { i18n } = useTranslation();
  const currency = currencyMap[i18n.language as keyof typeof currencyMap];

  const formatCurrency = (amount: number) => {
    return new Intl.NumberFormat(i18n.language, {
      style: 'currency',
      currency,
    }).format(amount);
  };

  return { formatCurrency };
}

// Usage
const { formatCurrency } = useCurrencyFormatter();
<Text>{formatCurrency(1299.99)}</Text> // $1,299.99 or R$ 1.299,99

Best Practices

✅ Use namespaces

Organize translations by feature: auth., tasks., etc

✅ Keep translations in sync

Ensure all language files have the same keys

✅ Use interpolation

Don't concatenate strings, use t('key', { '{ value }' })

✅ Test all languages

Verify layout works with longer/shorter translations

Some languages (like German) have much longer words. Test your UI with all languages!

Dynamic Content

For user-generated content that shouldn't be translated:

typescript
const { t } = useTranslation();

// Translate the label, not the user data
<View>
  <Text className="font-semibold">{t('profile.name')}</Text>
  <Text>{user.name}</Text> {/* User data, don't translate */}
</View>

// Translate static parts
<Text>
  {t('tasks.createdBy', { author: task.author })}
</Text>
// Translation: "Created by {{author}}"

Next Steps