Kotlin Multiplatform i18n: Shared Localization Across Platforms

Write your translations once in shared Kotlin code. Ship them to Android, iOS, and the web with proper locale fallback chains.

1

Configure Gradle for KMP i18n

Add your i18n dependencies to the shared module's commonMain source set. You can pick moko-resources for XML-based strings, Lyricist for type-safe Compose strings, or both. kmp-localechain adds smart locale fallback on top of either.

build.gradle.kts
// build.gradle.kts (shared module)
plugins {
    kotlin("multiplatform")
    id("com.android.library")
}

kotlin {
    androidTarget()
    iosArm64()
    iosSimulatorArm64()
    js(IR) { browser(); nodejs() }

    sourceSets {
        val commonMain by getting {
            dependencies {
                // Option A: moko-resources (code-gen from XML)
                implementation("dev.icerock.moko:resources:0.24.4")

                // Option B: Lyricist (type-safe Compose strings)
                implementation("cafe.adriel.lyricist:lyricist:1.7.0")

                // Locale fallback chains (works with any library)
                implementation("com.i18nagent:locale-chain-kmp:0.1.0")
            }
        }
    }
}
All three libraries publish to Maven Central. Add them to commonMain dependencies so they are available on every target (Android, iOS, JS).
2

Define Shared String Types

Create Kotlin data classes in commonMain that hold all translatable strings. This is the single source of truth — every platform reads from the same type-safe definitions. No duplicate string files, no drift between platforms.

commonMain/.../Strings.kt
// commonMain/kotlin/com/myapp/Strings.kt
package com.myapp.i18n

/**
 * Shared string definitions — the single source of truth.
 * Each platform reads from the same keys.
 */
data class AppStrings(
    val greeting: String,
    val farewell: String,
    val itemCount: (count: Int) -> String,
    val nav: NavStrings,
)

data class NavStrings(
    val home: String,
    val settings: String,
    val about: String,
)

// English defaults
val EnStrings = AppStrings(
    greeting = "Hello!",
    farewell = "Goodbye!",
    itemCount = { count ->
        if (count == 1) "$count item" else "$count items"
    },
    nav = NavStrings(
        home = "Home",
        settings = "Settings",
        about = "About",
    ),
)

// Japanese
val JaStrings = AppStrings(
    greeting = "こんにちは!",
    farewell = "さようなら!",
    itemCount = { count -> "${count}個のアイテム" },
    nav = NavStrings(
        home = "ホーム",
        settings = "設定",
        about = "概要",
    ),
)

// Add more locales following the same pattern: DeStrings, EsStrings, etc.
Use lambda properties for plurals instead of separate singular/plural keys. The lambda receives the count and returns the correct form. This keeps plural logic in Kotlin where the compiler can check it.
3

Wire Up Android

In androidMain, implement the expect/actual pattern to read the device locale via java.util.Locale. Android can use shared Kotlin strings for business logic alongside standard values/strings.xml for system UI elements like notifications and widgets.

androidMain/.../StringProvider.kt
// androidMain/kotlin/com/myapp/StringProvider.kt
package com.myapp.i18n

import java.util.Locale

actual fun currentLocale(): String =
    Locale.getDefault().toLanguageTag()  // e.g. "pt-BR"

// Android can also use standard resources/values-*/strings.xml
// alongside the shared Kotlin definitions.
// Use shared strings for business logic, XML for system UI.

// In your Activity or Compose screen:
@Composable
fun GreetingScreen() {
    val strings = rememberStrings()  // resolves via locale
    Text(text = strings.greeting)
    Text(text = strings.itemCount(cartSize))
}
Locale.getDefault().toLanguageTag() returns IETF tags like "pt-BR", but some Android versions return "pt-rBR" from older APIs. Always use toLanguageTag() (API 21+) for consistent results.
4

Wire Up iOS

In iosMain, implement currentLocale() using NSLocale from Foundation. The KMP shared framework exports your string definitions to Swift, so SwiftUI views can call them directly through the generated Kotlin framework.

iosMain/.../StringProvider.kt
// iosMain/kotlin/com/myapp/StringProvider.kt
package com.myapp.i18n

import platform.Foundation.NSLocale
import platform.Foundation.currentLocale
import platform.Foundation.languageCode
import platform.Foundation.countryCode

actual fun currentLocale(): String {
    val locale = NSLocale.currentLocale
    val lang = locale.languageCode
    val country = locale.countryCode
    return if (country != null) "$lang-$country" else lang
}

// In SwiftUI (via KMP exported framework):
// let strings = StringProviderKt.stringsFor(locale: "ja")
// Text(strings.greeting)
When exporting the KMP framework to Xcode, ensure you export the string provider module. In your Podspec or XCFramework config, include the i18n package so Swift code can import it.
5

Wire Up JS/Browser

In jsMain, read the browser locale from window.navigator.language. This covers both Kotlin/JS web apps and Compose for Web targets. The same shared strings render in the browser with zero duplication.

jsMain/.../StringProvider.kt
// jsMain/kotlin/com/myapp/StringProvider.kt
package com.myapp.i18n

import kotlinx.browser.window

actual fun currentLocale(): String =
    window.navigator.language  // e.g. "en-US", "pt-BR"

// In a Kotlin/JS or Compose for Web app:
fun main() {
    val locale = currentLocale()
    val strings = stringsFor(locale)
    document.getElementById("greeting")?.textContent = strings.greeting
}
For server-side Kotlin/JS (Node.js), read the locale from the Accept-Language header or a configuration variable instead of window.navigator.language.
6

Lyricist for Compose Multiplatform

Lyricist provides a Compose-native approach to i18n. Annotate your string objects with @LyricistStrings, and Lyricist generates a CompositionLocal provider. Switch languages at runtime by changing the languageTag — the UI recomposes automatically.

Lyricist integration
// Using Lyricist for Compose Multiplatform
// build.gradle.kts
plugins {
    id("cafe.adriel.lyricist") version "1.7.0"
}

// Define strings with @LyricistStrings annotation
@LyricistStrings(languageTag = Locales.EN, default = true)
val EnStrings = Strings(
    greeting = "Hello!",
    farewell = "Goodbye!",
    itemCount = { count ->
        if (count == 1) "$count item" else "$count items"
    },
)

@LyricistStrings(languageTag = Locales.JA)
val JaStrings = Strings(
    greeting = "こんにちは!",
    farewell = "さようなら!",
    itemCount = { count -> "${count}個のアイテム" },
)

// In your Compose UI
@Composable
fun App() {
    // Lyricist provides the strings via CompositionLocal
    ProvideStrings {
        val lyricist = LocalStrings.current
        Text(text = lyricist.greeting)
    }
}

// Switch language at runtime
val lyricist = rememberLyricist(
    defaultLanguageTag = Locales.EN,
)
lyricist.languageTag = Locales.JA  // UI recomposes automatically
Lyricist supports string interpolation, plurals via lambdas, and nested string groups. It works on Android, iOS (via Compose for iOS), Desktop, and Web targets.
7

moko-resources for XML Strings

moko-resources uses Android-style XML string files as the source of truth and generates type-safe accessors. Define strings in commonMain/resources/MR/base/ (English) and add locale folders for each language. The generated MR object provides compile-time checked access.

moko-resources setup
// Using moko-resources for XML-based string management
// build.gradle.kts
plugins {
    id("dev.icerock.mobile.multiplatform-resources") version "0.24.4"
}

multiplatformResources {
    resourcesPackage.set("com.myapp")
}

// commonMain/resources/MR/base/strings.xml (English - default)
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="greeting">Hello!</string>
    <string name="farewell">Goodbye!</string>
    <plurals name="item_count">
        <item quantity="one">%d item</item>
        <item quantity="other">%d items</item>
    </plurals>
</resources>

// commonMain/resources/MR/ja/strings.xml (Japanese)
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="greeting">こんにちは!</string>
    <string name="farewell">さようなら!</string>
    <plurals name="item_count">
        <item quantity="other">%d個のアイテム</item>
    </plurals>
</resources>

// Usage in shared Kotlin code
val greeting = MR.strings.greeting.desc()
val items = MR.plurals.item_count.format(count)
moko-resources requires the Gradle plugin to generate code. If you see 'Unresolved reference: MR', run a Gradle sync first. The code generation step must complete before your IDE sees the MR accessors.
8

Smart Locale Fallback with kmp-localechain

KMP i18n libraries lack configurable fallback chains. When pt-BR translations are missing, they skip pt-PT entirely and show English. kmp-localechain fixes this with a standalone message-merging utility. It takes flat Map<String, String> messages per locale and returns a merged map with fallback chain priority applied.

LocaleChain usage
// Using kmp-localechain for smart locale fallback
import com.i18nagent.localechain.LocaleChain

// 1. Configure once at app startup
LocaleChain.configure()  // uses built-in fallback chains

// 2. Load your messages as flat maps
val messages = mapOf(
    "en" to mapOf("greeting" to "Hello", "farewell" to "Goodbye"),
    "pt" to mapOf("greeting" to "Olá", "farewell" to "Adeus"),
    "pt-PT" to mapOf("greeting" to "Olá (PT)"),
    "pt-BR" to mapOf("greeting" to "Oi"),
)

// 3. Resolve with chain priority
val resolved = LocaleChain.resolve("pt-BR", messages)
// "greeting" -> "Oi"       (from pt-BR, most specific)
// "farewell" -> "Adeus"    (from pt, next in chain)

// Without LocaleChain, pt-BR users would see English "Goodbye"
// because pt-BR has no "farewell" key.
Custom configuration
// Custom fallback configuration
LocaleChain.configure(
    defaultLocale = "en",
    overrides = mapOf(
        "es-MX" to listOf("es-419", "es"),
        "fr-CA" to listOf("fr"),
    )
)

// Inspect any chain
LocaleChain.chainFor("pt-BR")
// Returns: ["pt-BR", "pt-PT", "pt", "en"]

// Async resolve (lazy loading from network/disk)
val resolved = LocaleChain.resolve("pt-BR") { localeTag ->
    api.fetchMessages(localeTag)  // returns Map<String, String>?
}
kmp-localechain works with flat Map<String, String> maps. If your messages are nested, flatten them before passing to resolve(). The library does not support deep merge of nested structures.
9

Automate Translations

With your KMP i18n setup complete, automate translations using AI. Translate your shared string files — whether Kotlin data classes, XML resources, or JSON — directly from your IDE or CI/CD pipeline.

Terminal
# Translate your shared string files with i18n Agent
# Works with JSON, XML (moko-resources), or any i18n format

# From your IDE (Claude Code, Cursor, VS Code):
> Translate commonMain/resources/MR/base/strings.xml to Japanese, German, and Spanish

✓ MR/ja/strings.xml created (1.2s)
✓ MR/de/strings.xml created (1.1s)
✓ MR/es/strings.xml created (1.3s)

# Or use the CLI in CI/CD:
npx i18n-agent translate resources/base/strings.xml --lang ja,de,es
Translate incrementally. When you add new keys to your English source, translate just the diff. This preserves human-reviewed translations and avoids regenerating entire files.

Automate Translation Quality

Catch missing keys and broken placeholders before they ship with i18n-validate. Test your UI with pseudo-translations using i18n-pseudo before real translations arrive.

Common Pitfalls

expect/actual Mismatch

Every expect declaration in commonMain needs an actual implementation in every target (androidMain, iosMain, jsMain). If you add a new platform target later, the compiler will error until you provide the actual. Use IDE quick-fixes to generate stubs.

Hardcoded Plural Logic

Never use count == 1 to detect singular forms. French treats 0 as singular. Arabic has six plural forms. Russian uses different forms for numbers ending in 1, 2-4, and 5-20. Use CLDR-aware libraries (moko-resources) or explicit lambdas per locale.

Nested Maps in kmp-localechain

kmp-localechain operates on flat Map<String, String>. If you pass nested maps, fallback resolution will not merge inner keys correctly. Flatten your messages using dot-notation keys (e.g., "nav.home") before calling resolve().

Missing Generated Code After Adding moko-resources

The MR object is generated by a Gradle plugin. After adding moko-resources strings, run a Gradle sync before using MR.strings.* in your code. If your IDE still shows errors, try Build > Rebuild Project.

Recommended Project Structure

Project Structure
my-kmp-app/
├── shared/
│   ├── build.gradle.kts
│   └── src/
│       ├── commonMain/
│       │   ├── kotlin/com/myapp/i18n/
│       │   │   ├── Strings.kt           # Shared string definitions
│       │   │   ├── StringProvider.kt     # expect fun currentLocale()
│       │   │   └── LocaleSetup.kt       # LocaleChain configuration
│       │   └── resources/MR/            # moko-resources XML (optional)
│       │       ├── base/strings.xml     # English (default)
│       │       ├── ja/strings.xml
│       │       ├── de/strings.xml
│       │       └── es/strings.xml
│       ├── androidMain/
│       │   └── kotlin/com/myapp/i18n/
│       │       └── StringProvider.kt     # actual fun currentLocale()
│       ├── iosMain/
│       │   └── kotlin/com/myapp/i18n/
│       │       └── StringProvider.kt     # actual fun currentLocale()
│       └── jsMain/
│           └── kotlin/com/myapp/i18n/
│               └── StringProvider.kt     # actual fun currentLocale()
├── androidApp/
│   └── src/main/res/values/strings.xml   # Android-specific overrides
├── iosApp/
│   └── iosApp/Localizable.strings        # iOS-specific overrides
└── settings.gradle.kts

Try i18n Agent Now

Drop your translation file here

JSON, YAML, PO, XML, CSV, Markdown, Properties

or click to browse

Target languages

No signup requiredInstant estimate

Frequently Asked Questions