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 |
All Kotlin targets |
Stable core; some format modules are Experimental |
kotlinx-datetime 0.8.0 |
Time zones, |
All Kotlin targets |
Pre-1.0 (0.x) but widely used |
kotlinx-io 0.9.1 |
Byte streams, cached GPX files, |
JVM, Native, JS, Wasm (file system access varies) |
Pre-1.0 (0.x); the layer under Ktor |
kotlinx-collections-immutable 0.4.0 |
|
All Kotlin targets |
Pre-1.0 (0.x) |
Kermit 2.2.0 |
One logging call that reaches Logcat, |
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 |
Serializing the domain |
Use a DTO and map it; the wire format then evolves independently of your UI model. |
Creating a new |
Define one shared instance; it caches serializers and is thread-safe. |
Relying on enum names the server may extend |
Decode to |
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 |
|
In the standard library; no dependency |
Measuring a duration |
|
Value class, no allocation |
A point on the timeline |
|
Absolute, zone independent; store this |
A calendar date or wall-clock time |
|
Needs calendar rules the standard library omits |
Zone conversions |
|
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.
See kotlinx-io on GitHub.
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.
See Kermit documentation and Kermit on GitHub.
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.
See Coil documentation.
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.
See Store on GitHub.
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 |
Is it a real multiplatform artifact? |
A Gradle module metadata file listing per-target variants, not just an Android |
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 |
Who stands behind it? |
JetBrains ( |
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 |
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 |
The build fails for iOS. Check klibs.io first, or hide the JVM library behind an interface in |
Importing |
Use kotlinx-datetime and kotlinx-io, which compile for every target. |
Importing |
Import |
Using a different version of each |
Put versions in |
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/actualcode, 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
Jsoninstance. -
ClockandInstantnow live inkotlin.time; kotlinx-datetime addsTimeZone,LocalDateand arithmetic. -
kotlinx-io gives common code
Path,SourceandSink; 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.