Independent product comparisons & buying guides - We earn from qualifying purchases
Back to Blog
Ultimate I18n Guide for Android, React, and Vue: JSON, XML, and Translation Best Practices
General Articles

Ultimate I18n Guide for Android, React, and Vue: JSON, XML, and Translation Best Practices

December 11, 2025
Jonas Höttler
15 min read

Internationalization (i18n) is essential for reaching a global audience with your app. Whether you're building for Android, React, or Vue, this comprehensive guide covers everything you need to know about implementing i18n correctly—from file formats to best practices and common pitfalls.

Table of Contents

  1. What is i18n and Why It Matters
  2. Translation File Formats: JSON vs XML
  3. Android i18n Implementation
  4. React i18n with i18next
  5. Vue i18n Implementation
  6. Cross-Platform Best Practices
  7. Common Pitfalls and How to Avoid Them
  8. Translation Management Workflow
  9. Library Comparison

What is i18n and Why It Matters {#what-is-i18n}

i18n (internationalization) is the process of designing your application so it can be adapted to various languages and regions without engineering changes. The term "i18n" comes from the 18 letters between "i" and "n" in "internationalization."

Key Benefits:

  • Larger market reach: Access 75% of internet users who don't speak English
  • Better user experience: Users prefer apps in their native language
  • Increased revenue: Localized apps see 26% higher conversion rates
  • SEO advantages: Rank for local language search queries

i18n vs L10n:

  • i18n (Internationalization): Preparing your code for multiple languages
  • L10n (Localization): Actually translating content for specific locales

Translation File Formats: JSON vs XML {#file-formats}

JSON Format

JSON is the preferred format for web applications (React, Vue) and increasingly used in mobile apps.

Pros:

  • Lightweight and easy to parse
  • Native support in JavaScript
  • Nested structures for organization
  • Wide tooling support

Example structure:

{
  "common": {
    "save": "Save",
    "cancel": "Cancel",
    "delete": "Delete",
    "loading": "Loading..."
  },
  "auth": {
    "login": "Log In",
    "logout": "Log Out",
    "register": "Create Account",
    "forgotPassword": "Forgot Password?"
  },
  "errors": {
    "network": "Network error. Please try again.",
    "required": "This field is required",
    "invalidEmail": "Please enter a valid email"
  }
}

XML Format

XML is the native format for Android and provides more structure for complex translations.

Pros:

  • Native Android support
  • Built-in pluralization
  • Comment support for translators
  • Strong typing with attributes

Example structure:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="save">Save</string>
    <string name="cancel">Cancel</string>

    <!-- Authentication -->
    <string name="login">Log In</string>
    <string name="logout">Log Out</string>

    <!-- Plurals -->
    <plurals name="items_count">
        <item quantity="one">%d item</item>
        <item quantity="other">%d items</item>
    </plurals>
</resources>

When to Use Which Format

Use CaseRecommended Format
Android NativeXML (strings.xml)
React/Vue WebJSON
React NativeJSON
Cross-platform (shared)JSON
Complex pluralizationXML or ICU format

Android i18n Implementation {#android-i18n}

Project Structure

app/src/main/res/
├── values/
│   └── strings.xml          # Default (English)
├── values-de/
│   └── strings.xml          # German
├── values-es/
│   └── strings.xml          # Spanish
├── values-fr/
│   └── strings.xml          # French
├── values-zh-rCN/
│   └── strings.xml          # Chinese (Simplified)
└── values-ar/
    └── strings.xml          # Arabic (RTL)

Basic strings.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">My App</string>
    <string name="welcome_title">Welcome</string>
    <string name="welcome_message">Thanks for using our app!</string>

    <!-- With parameters -->
    <string name="greeting">Hello, %1$s!</string>
    <string name="items_selected">%1$d of %2$d selected</string>

    <!-- With HTML -->
    <string name="terms_link"><![CDATA[
        I agree to the <a href="https://example.com/terms">Terms of Service</a>
    ]]></string>
</resources>

Using Strings in Kotlin

// In Activity/Fragment
val welcome = getString(R.string.welcome_title)
val greeting = getString(R.string.greeting, userName)

// Plurals
val itemsText = resources.getQuantityString(
    R.plurals.items_count,
    count,
    count
)

// In Jetpack Compose
@Composable
fun WelcomeScreen() {
    Text(text = stringResource(R.string.welcome_title))
    Text(text = stringResource(R.string.greeting, userName))
}

Android JSON Alternative

For dynamic translations or server-driven content:

class TranslationManager(private val context: Context) {
    private var translations: Map<String, Any> = emptyMap()

    suspend fun loadTranslations(locale: String) {
        // Load from assets or API
        val json = context.assets.open("i18n/$locale.json")
            .bufferedReader().use { it.readText() }
        translations = Json.decodeFromString(json)
    }

    fun t(key: String, vararg args: Any): String {
        val keys = key.split(".")
        var value: Any? = translations

        for (k in keys) {
            value = (value as? Map<*, *>)?.get(k)
        }

        return (value as? String)?.let {
            if (args.isNotEmpty()) String.format(it, *args) else it
        } ?: key
    }
}

// Usage
val tm = TranslationManager(context)
tm.loadTranslations("de")
val text = tm.t("auth.login") // "Anmelden"

React i18n with i18next {#react-i18n}

Installation

npm install i18next react-i18next i18next-browser-languagedetector

Project Structure

src/
├── i18n/
│   ├── index.ts
│   └── locales/
│       ├── en/
│       │   ├── common.json
│       │   └── auth.json
│       ├── de/
│       │   ├── common.json
│       │   └── auth.json
│       └── es/
│           ├── common.json
│           └── auth.json
└── App.tsx

i18n Configuration

// src/i18n/index.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';

// Import translations
import enCommon from './locales/en/common.json';
import enAuth from './locales/en/auth.json';
import deCommon from './locales/de/common.json';
import deAuth from './locales/de/auth.json';

const resources = {
  en: {
    common: enCommon,
    auth: enAuth,
  },
  de: {
    common: deCommon,
    auth: deAuth,
  },
};

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources,
    fallbackLng: 'en',
    defaultNS: 'common',

    interpolation: {
      escapeValue: false, // React already escapes
    },

    detection: {
      order: ['localStorage', 'navigator', 'htmlTag'],
      caches: ['localStorage'],
    },
  });

export default i18n;

Translation Files

// locales/en/common.json
{
  "save": "Save",
  "cancel": "Cancel",
  "delete": "Delete",
  "loading": "Loading...",
  "greeting": "Hello, {{name}}!",
  "itemsCount_one": "{{count}} item",
  "itemsCount_other": "{{count}} items"
}
// locales/de/common.json
{
  "save": "Speichern",
  "cancel": "Abbrechen",
  "delete": "Löschen",
  "loading": "Wird geladen...",
  "greeting": "Hallo, {{name}}!",
  "itemsCount_one": "{{count}} Artikel",
  "itemsCount_other": "{{count}} Artikel"
}

Using Translations in Components

import { useTranslation } from 'react-i18next';

function MyComponent() {
  const { t, i18n } = useTranslation();

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

  return (
    <div>
      <h1>{t('greeting', { name: 'Max' })}</h1>
      <p>{t('itemsCount', { count: 5 })}</p>

      <button onClick={() => changeLanguage('en')}>English</button>
      <button onClick={() => changeLanguage('de')}>Deutsch</button>
    </div>
  );
}

i18next Placeholder Syntax

i18next uses double curly braces for interpolation:

{
  "welcome": "Welcome, {{username}}!",
  "orderStatus": "Order #{{orderId}} is {{status}}",
  "price": "Price: {{amount, currency(USD)}}",
  "date": "Date: {{date, datetime}}"
}
t('welcome', { username: 'John' })
t('orderStatus', { orderId: 123, status: 'shipped' })

React i18next Pluralization

i18next handles pluralization with suffixes:

{
  "message_zero": "No messages",
  "message_one": "One message",
  "message_other": "{{count}} messages"
}
t('message', { count: 0 })  // "No messages"
t('message', { count: 1 })  // "One message"
t('message', { count: 5 })  // "5 messages"

Vue i18n Implementation {#vue-i18n}

Installation

npm install vue-i18n@9

Project Structure

src/
├── i18n/
│   ├── index.ts
│   └── locales/
│       ├── en.json
│       ├── de.json
│       └── es.json
├── main.ts
└── App.vue

Vue i18n Configuration

// src/i18n/index.ts
import { createI18n } from 'vue-i18n';
import en from './locales/en.json';
import de from './locales/de.json';

const i18n = createI18n({
  legacy: false, // Use Composition API
  locale: 'en',
  fallbackLocale: 'en',
  messages: {
    en,
    de,
  },
});

export default i18n;
// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import i18n from './i18n';

const app = createApp(App);
app.use(i18n);
app.mount('#app');

Translation Files

// locales/en.json
{
  "nav": {
    "home": "Home",
    "about": "About",
    "contact": "Contact"
  },
  "auth": {
    "login": "Log In",
    "logout": "Log Out"
  },
  "greeting": "Hello, {name}!",
  "items": "no items | one item | {count} items"
}
// locales/de.json
{
  "nav": {
    "home": "Startseite",
    "about": "Über uns",
    "contact": "Kontakt"
  },
  "auth": {
    "login": "Anmelden",
    "logout": "Abmelden"
  },
  "greeting": "Hallo, {name}!",
  "items": "keine Artikel | ein Artikel | {count} Artikel"
}

Vue i18n in Templates

<script setup lang="ts">
import { useI18n } from 'vue-i18n';

const { t, locale } = useI18n();

function changeLanguage(lang: string) {
  locale.value = lang;
}
</script>

<template>
  <nav>
    <a href="/">{{ t('nav.home') }}</a>
    <a href="/about">{{ t('nav.about') }}</a>
  </nav>

  <h1>{{ t('greeting', { name: 'Max' }) }}</h1>
  <p>{{ t('items', 5) }}</p>

  <select v-model="locale">
    <option value="en">English</option>
    <option value="de">Deutsch</option>
  </select>
</template>

Vue i18n Translate in JS

For translations outside templates:

// In Composition API
import { useI18n } from 'vue-i18n';

export function useMyComposable() {
  const { t } = useI18n();

  function showNotification() {
    alert(t('notifications.success'));
  }

  return { showNotification };
}

// Outside components (e.g., in stores)
import i18n from '@/i18n';

export function getErrorMessage(code: string) {
  return i18n.global.t(`errors.${code}`);
}

Cross-Platform Best Practices {#best-practices}

1. Consistent Key Naming

Use the same key structure across all platforms:

Platform    | Key Format
------------|------------------
Android     | auth_login
React       | auth.login
Vue         | auth.login

Recommended: Use a shared source of truth

// shared/translations/en.json
{
  "auth": {
    "login": "Log In",
    "logout": "Log Out"
  }
}

Then transform for each platform:

// build-scripts/transform-android.js
function toAndroidXml(json) {
  let xml = '<?xml version="1.0" encoding="utf-8"?>\n<resources>\n';

  function flatten(obj, prefix = '') {
    for (const [key, value] of Object.entries(obj)) {
      const fullKey = prefix ? `${prefix}_${key}` : key;
      if (typeof value === 'string') {
        xml += `    <string name="${fullKey}">${escapeXml(value)}</string>\n`;
      } else {
        flatten(value, fullKey);
      }
    }
  }

  flatten(json);
  xml += '</resources>';
  return xml;
}

2. Placeholder Standardization

Different frameworks use different placeholder syntax:

PlatformSyntaxExample
Android%1$s, %2$dHello, %1$s!
i18next{{name}}Hello, {{name}}!
Vue i18n{name}Hello, {name}!
ICU{name}Hello, {name}!

Solution: Use ICU Message Format as source

{
  "greeting": "Hello, {name}!"
}

Transform during build:

// For Android
"Hello, %1$s!"

// For i18next
"Hello, {{name}}!"

3. Pluralization Strategy

Use ICU plural format as the standard:

{
  "items": "{count, plural, =0 {No items} one {# item} other {# items}}"
}

4. Context for Translators

Always provide context:

{
  "play": "Play",
  "_play.context": "Button to start video playback",

  "play_game": "Play",
  "_play_game.context": "Button to start a game"
}

For Android XML:

<string name="play" comment="Button to start video playback">Play</string>

5. Avoid String Concatenation

// ❌ Bad - word order varies by language
const msg = t('hello') + ' ' + name + '!';

// ✅ Good
const msg = t('greeting', { name });

6. Handle Text Expansion

German text is ~30% longer than English. Design for flexibility:

/* Allow buttons to grow */
.button {
  min-width: 100px;
  width: auto;
  padding: 8px 16px;
}

7. RTL Support

Always design with RTL in mind:

/* Use logical properties */
.card {
  margin-inline-start: 16px;  /* Not margin-left */
  padding-inline-end: 8px;    /* Not padding-right */
}
<!-- Android: Use start/end instead of left/right -->
<TextView
    android:layout_marginStart="16dp"
    android:layout_marginEnd="8dp" />

Common Pitfalls and How to Avoid Them {#pitfalls}

Pitfall 1: Missing Fallback Language

Problem: App crashes when translation is missing.

Solution:

// React i18next
i18n.init({
  fallbackLng: 'en',
  saveMissing: true,
  missingKeyHandler: (lng, ns, key) => {
    console.warn(`Missing translation: ${key}`);
  }
});

Pitfall 2: Hardcoded Strings in Code

Problem: Strings scattered throughout the codebase.

Solution: Use linting rules:

// .eslintrc
{
  "rules": {
    "i18next/no-literal-string": "error"
  }
}

Pitfall 3: Date/Number Formatting

Problem: Using string concatenation for dates.

Solution: Use Intl API:

// Dates
const formatted = new Intl.DateTimeFormat(locale, {
  dateStyle: 'long'
}).format(date);

// Numbers
const price = new Intl.NumberFormat(locale, {
  style: 'currency',
  currency: 'EUR'
}).format(19.99);

Pitfall 4: Incomplete Translations

Problem: Shipping with missing translations.

Solution: Add CI checks:

# Check for missing translations
npx i18next-parser --fail-on-warnings

Pitfall 5: Translation in Wrong Context

Problem: Same word, different meaning ("Save" as in save file vs save money).

Solution: Use unique keys with context:

{
  "save_file": "Save",
  "save_money": "Save",
  "post_noun": "Post",
  "post_verb": "Post"
}

Pitfall 6: Gendered Language

Problem: Languages like German have grammatical gender.

Solution: Use ICU SelectFormat:

{
  "welcome": "{gender, select, male {Willkommen, Herr {name}} female {Willkommen, Frau {name}} other {Willkommen, {name}}}"
}

Translation Management Workflow {#workflow}

Recommended Workflow

1. Developer adds key    →  "auth.login": "Log In"
2. Push to repository    →  Git commit
3. CI extracts strings   →  i18next-parser / formatjs extract
4. Sync to TMS           →  Crowdin / Lokalise / Phrase
5. Translators work      →  In TMS interface
6. Auto-sync back        →  Webhook / CI job
7. Deploy with new       →  Build includes translations
   translations

Popular Translation Management Systems

ToolBest ForPricing
CrowdinOpen source, large teamsFree for OSS
LokaliseDeveloper experienceFrom $120/mo
PhraseEnterpriseFrom $25/mo
POEditorSmall teamsFree tier available
TransifexLarge projectsFrom $99/mo

CI Integration Example

# .github/workflows/i18n.yml
name: i18n Sync

on:
  push:
    paths:
      - 'src/i18n/locales/**'

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Extract new keys
        run: npx i18next-parser

      - name: Upload to Crowdin
        uses: crowdin/github-action@v1
        with:
          upload_sources: true
          upload_translations: false
        env:
          CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
          CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_TOKEN }}

Library Comparison {#comparison}

React i18n Libraries

LibraryBundle SizeFeaturesBest For
i18next42kbFull-featured, pluginsMost projects
react-intl35kbICU format, formatjsEnterprise
LinguiJS5kbMinimal, compile-timePerformance

Vue i18n Libraries

LibraryBundle SizeFeaturesBest For
vue-i18n30kbOfficial, full-featuredMost projects
vue-i18next42kbi18next ecosystemCross-platform
fluent-vue15kbMozilla FluentComplex grammar

Android i18n Options

ApproachComplexityBest For
strings.xmlLowStandard apps
JSON + CustomMediumDynamic content
ICU4JHighComplex formatting

Quick Start Checklist

  • Set up translation file structure
  • Configure fallback language
  • Implement language switcher
  • Add RTL support
  • Set up pluralization
  • Configure date/number formatting
  • Add missing translation warnings
  • Set up CI extraction
  • Connect translation management tool
  • Document translation process for team

Conclusion

Implementing i18n correctly from the start saves countless hours later. Key takeaways:

  1. Choose the right format: JSON for web, XML for Android native
  2. Standardize across platforms: Use ICU format as source of truth
  3. Avoid common pitfalls: No hardcoding, no concatenation
  4. Automate the workflow: CI/CD integration with TMS
  5. Test with real languages: Don't just test with English

Whether you're building for Android, React, or Vue, following these best practices ensures your app can scale globally with minimal friction.

Further Reading

Share:

More Articles