Essential Multiplatform Libraries

This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

The best multiplatform code is code you did not write. Kotlin Multiplatform has a mature set of libraries that already compile for Android, iOS, desktop and the web, and the decision ladder for platform APIs puts "use a multiplatform library" on its first rung for a reason. This page is a curated tour of the libraries a typical app needs on day one — serialization, dates and times, byte streams and files, immutable collections, logging, images, Firebase and caching — with a short TrailMate example for each, followed by a method for judging any library you meet later.

The baseline is the one used across this section: Kotlin 2.4.20, Compose Multiplatform 1.12.1, AGP 9.4, Ktor 3.6.0, SQLDelight 2.4.0, Koin 4.2.2, SKIE 0.10.15, KMP-NativeCoroutines 1.0.6, Room 3 (androidx.room3 3.0.3), Mokkery 3.5.0 and Turbine 1.2.1. Swift export and SwiftPM import are Alpha; Kotlin/Wasm and Compose Multiplatform for the web are Beta. Library versions shown below are the latest checked at the time of writing; always confirm the current release on the library’s own page. How dependencies are declared and resolved per target is covered on Dependencies, klibs.io, cinterop and SwiftPM Import, and the module layout on Project Structure, Targets, Source Sets and the Hierarchy Template.

The Tour at a Glance

Library What it gives TrailMate Targets Maturity

kotlinx-serialization 1.11.0

JSON for TrailDto, persisted settings, navigation arguments

All Kotlin targets

Stable core; some format modules are Experimental

kotlinx-datetime 0.8.0

Time zones, LocalDate, hike dates; pairs with kotlin.time.Clock

All Kotlin targets

Pre-1.0 (0.x) but widely used

kotlinx-io 0.9.1

Byte streams, cached GPX files, Path

JVM, Native, JS, Wasm (file system access varies)

Pre-1.0 (0.x); the layer under Ktor

kotlinx-collections-immutable 0.4.0

ImmutableList<Trail> in UI state

All Kotlin targets

Pre-1.0 (0.x)

Kermit 2.2.0

One logging call that reaches Logcat, os_log and the console

Android, iOS, desktop, web

Stable, maintained by Touchlab

Coil 3 3.6.3

Trail photos in Compose Multiplatform

Android, iOS, desktop, web

Stable

GitLive Firebase 2.7.0

Synced favorites through Firestore

Android, iOS, JS (some products only)

Third-party wrapper over the native SDKs

Store5

Cache-then-network reads with request deduplication

Android, iOS, desktop, web

Mention only on this page

kotlinx-serialization

kotlinx-serialization is JetBrains' serialization framework. A compiler plugin generates a serializer for every class annotated with @Serializable at compile time — no reflection — which is what makes it work on Kotlin/Native and Kotlin/Wasm where reflection is unavailable. The JSON module (kotlinx-serialization-json) is the one you will use most; Ktor’s content negotiation and Compose navigation arguments both build on it. Protobuf, CBOR and other formats are separate modules, several of which are still Experimental.

Apply the plugin and add the JSON runtime to commonMain:

// sharedLogic/build.gradle.kts
plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidKotlinMultiplatformLibrary) // com.android.kotlin.multiplatform.library
    alias(libs.plugins.kotlinSerialization)               // org.jetbrains.kotlin.plugin.serialization
}

kotlin {
    android {
        namespace = "com.example.trailmate.shared"
        compileSdk = 37
        minSdk = 24
    }
    iosArm64()
    iosSimulatorArm64()
    jvm("desktop")

    sourceSets {
        commonMain.dependencies {
            implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
        }
    }
}

Then describe the wire format of the TrailMate API. The DTO is deliberately separate from the domain Trail so that a renamed JSON field never leaks into the UI.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/remote/TrailDto.kt
package com.example.trailmate.data.remote

import com.example.trailmate.domain.Difficulty
import com.example.trailmate.domain.Trail
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class TrailDto(
    val id: String,
    val name: String,
    val region: String,
    @SerialName("length_km") val lengthKm: Double,
    val difficulty: String = "MODERATE",
    @SerialName("photo_url") val photoUrl: String? = null,
)

fun TrailDto.toDomain(isFavorite: Boolean) = Trail(
    id = id,
    name = name,
    region = region,
    lengthKm = lengthKm,
    difficulty = Difficulty.valueOf(difficulty),
    photoUrl = photoUrl,
    isFavorite = isFavorite,
)

/** Shared configuration: tolerate fields the server adds later. */
val TrailJson = Json {
    ignoreUnknownKeys = true
    explicitNulls = false
}

fun parseTrails(body: String): List<TrailDto> = TrailJson.decodeFromString(body)

ignoreUnknownKeys is the setting that keeps old app versions working when the backend adds a field; without it a single new property makes decoding throw. explicitNulls = false omits null properties when encoding and treats missing ones as null when decoding.

Common serialization mistakes

Mistake Fix

Forgetting the Gradle plugin and getting "Serializer for class not found"

The runtime library alone is not enough; apply org.jetbrains.kotlin.plugin.serialization in every module that declares @Serializable classes.

Serializing the domain Trail directly

Use a DTO and map it; the wire format then evolves independently of your UI model.

Creating a new Json \{ …​ } per call

Define one shared instance; it caches serializers and is thread-safe.

Relying on enum names the server may extend

Decode to String (as above) or use coerceInputValues = true with a default.

kotlinx-datetime and kotlin.time

Date and time is where multiplatform code most often cheats with java.time, which exists only on the JVM. kotlinx-datetime supplies the calendar types the standard library does not: LocalDate, LocalTime, LocalDateTime, TimeZone, DatePeriod and arithmetic between them. The two types that represent a point on the timeline, Instant and Clock, have moved into the Kotlin standard library as kotlin.time.Instant and kotlin.time.Clock; they are Stable from Kotlin 2.3, and kotlinx-datetime 0.7 and later builds on them, so older code that imports kotlinx.datetime.Clock must switch its imports.

The mental model is simple: an Instant is an absolute moment, a TimeZone is a set of rules, and a LocalDateTime is what a wall clock would show in that zone. Convert at the edges, and keep Instant in your domain and database.

// sharedLogic/build.gradle.kts (excerpt)
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
        }
    }
}

TrailMate shows "Hiked on 12 March" and needs to know whether a trail was completed today. Injecting a Clock makes the logic testable with a fixed time:

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/domain/HikeDates.kt
package com.example.trailmate.domain

import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime

class HikeDates(
    private val clock: Clock = Clock.System,
    private val zone: TimeZone = TimeZone.currentSystemDefault(),
) {
    fun today(): LocalDate = clock.now().toLocalDateTime(zone).date

    fun wasToday(completedAt: Instant): Boolean =
        completedAt.toLocalDateTime(zone).date == today()

    fun daysSince(completedAt: Instant): Int =
        completedAt.toLocalDateTime(zone).date.daysUntil(today())

    /** Suggests a follow-up hike one week after the last one. */
    fun nextSuggested(last: LocalDate): LocalDate = last.plus(1, DateTimeUnit.WEEK)
}

fun parseHikeInstant(text: String): Instant = Instant.parse(text) // "2026-03-12T08:30:00Z"

TimeZone.currentSystemDefault() is the multiplatform replacement for the expect/actual currentTimeZone() you might otherwise write: the library already contains the platform code. Note that it reads the zone when called; a long-running app that must follow the user travelling should call it again, not cache it. The zone database is bundled on JS and Wasm only through the optional kotlinx-datetime-zoneinfo artifact, so check your web target if you need named zones such as TimeZone.of("Europe/Madrid").

Formatting for display is a presentation concern. The library’s formatting API (format, DateTimeComponents) is Stable for ISO-style output, while localized, human-friendly formatting ("12 March") still belongs to the platform (DateFormatter in Swift, java.time.format on Android and desktop) behind an interface.

kotlin.time versus kotlinx-datetime

Need Use Why

"Now" and elapsed time

kotlin.time.Clock.System, TimeSource.Monotonic

In the standard library; no dependency

Measuring a duration

kotlin.time.Duration (2.hours)

Value class, no allocation

A point on the timeline

kotlin.time.Instant

Absolute, zone independent; store this

A calendar date or wall-clock time

LocalDate, LocalDateTime from kotlinx-datetime

Needs calendar rules the standard library omits

Zone conversions

TimeZone, toLocalDateTime, toInstant

Backed by each platform’s zone rules

kotlinx-io

kotlinx-io is a small, fast I/O library built around Buffer, Source (reading) and Sink (writing). It is the byte-level layer that Ktor itself builds on, and it gives common code a Path and a SystemFileSystem for reading and writing files without java.io.File. It is still a 0.x library, so its API can change between minor releases; pin the version in the catalog and read the release notes when you upgrade.

// sharedLogic/build.gradle.kts (excerpt)
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("org.jetbrains.kotlinx:kotlinx-io-core:0.9.1")
        }
    }
}

TrailMate caches the downloaded GPX track of each trail so it can be viewed offline. The directory itself is platform specific (Context.cacheDir on Android, the caches directory on iOS), so it arrives as a constructor parameter and the I/O code stays common:

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/local/GpxCache.kt
package com.example.trailmate.data.local

import kotlinx.io.buffered
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.readString
import kotlinx.io.writeString

class GpxCache(private val directory: Path) {

    init {
        SystemFileSystem.createDirectories(directory)
    }

    private fun fileFor(trailId: String) = Path(directory, "$trailId.gpx")

    fun write(trailId: String, gpx: String) {
        SystemFileSystem.sink(fileFor(trailId)).buffered().use { it.writeString(gpx) }
    }

    fun read(trailId: String): String? {
        val path = fileFor(trailId)
        if (!SystemFileSystem.exists(path)) return null
        return SystemFileSystem.source(path).buffered().use { it.readString() }
    }
}

SystemFileSystem is available on JVM, Android and Native; in the browser there is no file system, so a webApp build should use a different TrailCache implementation behind the same interface. Always wrap sources and sinks in use so the handle closes on errors.

kotlinx-collections-immutable

Kotlin’s List is a read-only view, not an immutable list: nothing stops a MutableList from being passed where a List is expected and changed later. Compose therefore cannot prove that a List<Trail> parameter is stable, and treats it as unstable, which can cause avoidable recomposition. kotlinx-collections-immutable provides ImmutableList, ImmutableSet, ImmutableMap and their persistent implementations, which share structure so that "modifying" a large collection copies very little.

// sharedLogic/build.gradle.kts (excerpt)
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0")
        }
    }
}
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/presentation/TrailListUiState.kt
package com.example.trailmate.presentation

import com.example.trailmate.domain.Trail
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList

data class TrailListUiState(
    val trails: ImmutableList<Trail> = persistentListOf(),
    val isLoading: Boolean = false,
)

fun TrailListUiState.withFavoriteToggled(id: String): TrailListUiState =
    copy(trails = trails.map { if (it.id == id) it.copy(isFavorite = !it.isFavorite) else it }.toPersistentList())

fun PersistentList<Trail>.prepend(trail: Trail): PersistentList<Trail> = add(0, trail)

Since Kotlin 2.0.20 the Compose compiler’s strong skipping mode is on by default, which makes plain List less costly than it once was, so adopt immutable collections where profiling or the compiler reports show unstable parameters rather than everywhere. One iOS caveat: an ImmutableList is a Kotlin interface, so SwiftUI code sees it as an opaque object rather than a Swift array. If the native-UI path observes this state, expose a plain List at the boundary (see the SKIE and KMP-NativeCoroutines pages of this section).

Logging with Kermit

Android has Logcat, iOS has os_log and the console, desktop has standard output, and the browser has the JavaScript console. Kermit, from Touchlab, hides them behind one API with pluggable LogWriter`s. You call `Logger.d in common code and each platform’s default writer decides where the line goes. Log messages are lambdas, so the string is not even built when the severity is filtered out.

// sharedLogic/build.gradle.kts (excerpt)
kotlin {
    sourceSets {
        commonMain.dependencies {
            api("co.touchlab:kermit:2.2.0")
        }
    }
}
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/OfflineFirstTrailRepository.kt
package com.example.trailmate.data

import co.touchlab.kermit.Logger
import com.example.trailmate.data.remote.TrailApiClient
import com.example.trailmate.domain.Trail
import com.example.trailmate.domain.TrailRepository

class OfflineFirstTrailRepository(
    private val api: TrailApiClient,
    // inject a tagged logger so tests can substitute a no-op writer
    private val log: Logger = Logger.withTag("OfflineFirstTrailRepository"),
) : TrailRepository {

    override suspend fun refresh(region: String?): Result<List<Trail>> =
        runCatching {
            log.d { "Refreshing trails for region=$region" }
            api.fetchTrails(region = region).map { it.toDomain(isFavorite = false) }
        }.onFailure { log.e(it) { "Refresh failed" } }
}

The default configuration is enough for development; for release builds, configure the minimum severity once at startup. A tiny entry point lets Swift do it without knowing Kermit exists:

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/AppLogging.kt
package com.example.trailmate

import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity

object AppLogging {
    fun install(debug: Boolean) {
        Logger.setMinSeverity(if (debug) Severity.Debug else Severity.Warn)
    }
}
// iosApp/iosApp/iOSApp.swift
import SwiftUI
import Shared

@main
struct iOSApp: App {
    init() {
        #if DEBUG
        AppLogging.shared.install(debug: true)
        #else
        AppLogging.shared.install(debug: false)
        #endif
    }

    var body: some Scene {
        WindowGroup { ContentView() }
    }
}

Connecting a crash reporter is done by adding a custom LogWriter that forwards Severity.Error entries; keep personal data such as exact GPS coordinates out of log messages, because logs are collected by crash tools and by adb logcat on shared devices.

Images with Coil 3

Coil 3 is the Kotlin Multiplatform release of the image loader known from Android, and it is the standard image library for Compose Multiplatform. AsyncImage loads, caches, decodes and displays a URL or other model on Android, iOS, desktop and the web. Unlike Coil 2 on Android it does not bundle a network stack: you add a network artifact, either OkHttp-based or the Ktor-based coil-network-ktor3, which fits a project that already uses Ktor. The Android-side concepts (placeholders, transformations, the Glide alternative) are covered on Image Loading with Coil and Glide; the multiplatform setup differs only in the artifacts and the platform engine.

// sharedUI/build.gradle.kts (excerpt)
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("io.coil-kt.coil3:coil-compose:3.6.3")
            implementation("io.coil-kt.coil3:coil-network-ktor3:3.6.3")
        }
        androidMain.dependencies { implementation("io.ktor:ktor-client-okhttp:3.6.0") }
        iosMain.dependencies { implementation("io.ktor:ktor-client-darwin:3.6.0") }
        // jvm("desktop") and wasmJs need their own Ktor engine, for example ktor-client-cio / ktor-client-js
    }
}
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/TrailPhoto.kt
package com.example.trailmate.ui

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage

@Composable
fun TrailPhoto(photoUrl: String?, trailName: String, modifier: Modifier = Modifier) {
    AsyncImage(
        model = photoUrl,
        contentDescription = "Photo of $trailName",
        contentScale = ContentScale.Crop,
        modifier = modifier.fillMaxWidth(),
    )
}

Coil 3 is only for Compose Multiplatform. If the iOS app uses SwiftUI (the native-UI path), load images there with AsyncImage from SwiftUI or a Swift package, and share only the URL from Trail.photoUrl. On iOS the ATS rules still apply: an http:// image URL is blocked unless an exception is configured.

Firebase with the GitLive SDK

Google’s Firebase SDKs are native (Kotlin/Java on Android, Swift on iOS). The community Firebase Kotlin SDK by GitLive wraps them in one Kotlin API (dev.gitlive:firebase-*) so that Authentication, Firestore, Remote Config, Crashlytics and more can be called from commonMain. It is a third-party project rather than a Google one, and it delegates to the real native SDK on each platform, so the platform setup is unchanged: google-services.json and the Google Services Gradle plugin on Android, GoogleService-Info.plist and the Firebase iOS SDK in the Xcode project. Each Firebase product is a separate artifact, and product coverage differs by target, so check the compatibility table in its README before relying on a product on desktop or web.

// sharedLogic/build.gradle.kts (excerpt)
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("dev.gitlive:firebase-auth:2.7.0")
            implementation("dev.gitlive:firebase-firestore:2.7.0")
        }
    }
}
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/remote/FavoritesSync.kt
package com.example.trailmate.data.remote

import dev.gitlive.firebase.Firebase
import dev.gitlive.firebase.auth.auth
import dev.gitlive.firebase.firestore.firestore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable

@Serializable
private data class FavoriteDoc(val trailId: String)

class FavoritesSync {
    private fun collection() = Firebase.firestore
        .collection("users")
        .document(requireNotNull(Firebase.auth.currentUser).uid)
        .collection("favorites")

    suspend fun add(trailId: String) {
        collection().document(trailId).set(FavoriteDoc(trailId))
    }

    fun observeIds(): Flow<Set<String>> =
        collection().snapshots.map { snapshot -> snapshot.documents.map { it.id }.toSet() }
}

Plain Firestore queries and listeners work well, but anything the SDK does not wrap still requires an expect/actual bridge to the native SDK. Security rules, not client code, protect user data, so write them before shipping. Never commit google-services.json or GoogleService-Info.plist for a production project to a public repository.

Store5, a Mention

Store5, from Mobile Native Foundation, is a reactive caching layer: you declare a Fetcher for the network and a SourceOfTruth for the database, and Store serves reads cache-first, deduplicates simultaneous identical requests and refreshes in the background. It targets Android, iOS, desktop and the web. That is exactly the policy TrailMate’s OfflineFirstTrailRepository implements by hand, so it is a candidate replacement once the hand-written version grows stale-while-revalidate, expiry and conflict rules. For a single list screen, the plain repository is smaller and clearer; adopt Store5 when the caching rules multiply.

How to Judge a Library

Every library you add becomes a permanent constraint on your targets and your upgrade path. Before adopting one, run this checklist.

Question How to answer it

Does it support every target you ship?

Open the library on klibs.io and read its target list; then confirm in the repository’s Gradle setup. A library that lacks iosSimulatorArm64 breaks your simulator builds, and one without wasmJs blocks the web app.

Is it a real multiplatform artifact?

A Gradle module metadata file listing per-target variants, not just an Android aar. Resolution fails per target when a variant is missing.

Is it maintained?

Look at the date of the last release, open issues, and whether it follows current Kotlin and AGP versions. A library still on Kotlin 1.9 will force a downgrade or a fork.

Is the API stable?

Pre-1.0 versions and @Experimental annotations may break between releases; isolate them behind your own interface.

Who stands behind it?

JetBrains (kotlinx-*, Ktor), Google (Jetpack KMP libraries), a company (Touchlab’s Kermit, Cash App’s SQLDelight) or a single volunteer. Bus factor matters.

Does it need a compiler plugin or KSP?

Plugins and processors are tied to the Kotlin version and can delay your upgrade (see the KSP discussion in the dependencies page).

Can you leave?

Wrap the library in a small interface of your own, as TrailRepository wraps the data layer, so replacing it touches one module.

klibs.io shows targets, licence, latest release and activity in one place, which makes the first four questions a two-minute task; it also has an MCP server that coding assistants can query, described in Dependencies, klibs.io, cinterop and SwiftPM Import.

Examples of stale choices

Older tutorials still recommend libraries that have since stalled. Napier, a Kotlin Multiplatform logger, is no longer actively developed, and Kermit above is the maintained alternative with the same shape of API. PreCompose, a navigation and ViewModel library for Compose Multiplatform, is likewise stale; its job is now covered by the official Jetpack ports (androidx.lifecycle ViewModel and Navigation for Compose Multiplatform), discussed on the Compose Multiplatform pages of this section. Treat an old tutorial’s dependency list as a starting point to check on klibs.io, not as a recommendation.

Common Mistakes

Mistake Fix

Adding a JVM-only library to commonMain

The build fails for iOS. Check klibs.io first, or hide the JVM library behind an interface in androidMain.

Importing java.time or java.io.File in shared code

Use kotlinx-datetime and kotlinx-io, which compile for every target.

Importing kotlinx.datetime.Clock after upgrading to kotlinx-datetime 0.7 or later

Import kotlin.time.Clock and kotlin.time.Instant.

Using a different version of each kotlinx-* library by hand

Put versions in libs.versions.toml and move them together; mixed versions cause binary incompatibilities.

Exposing Kermit, GitLive or Coil types in public API of a framework consumed from Swift

Keep the library an implementation detail; export only your own types.

Summary

  • Reach for a multiplatform library before writing expect/actual code, and verify its targets on klibs.io.

  • kotlinx-serialization generates serializers at compile time with a compiler plugin; keep DTOs separate from the domain model and share one Json instance.

  • Clock and Instant now live in kotlin.time; kotlinx-datetime adds TimeZone, LocalDate and arithmetic.

  • kotlinx-io gives common code Path, Source and Sink; it is a 0.x library, so pin its version.

  • Immutable collections help Compose stability but surface as opaque objects in Swift.

  • Kermit is the maintained multiplatform logger; Napier and PreCompose are stale.

  • Coil 3 is the image library for Compose Multiplatform and needs a network artifact.

  • The GitLive Firebase SDK wraps the native Firebase SDKs and needs the usual platform configuration.

  • Store5 is worth considering when caching rules outgrow a hand-written repository.