Kotlin Multiplatform: Build Apps for Android, iOS, and Web
Kotlin Multiplatform is one of the most practical ways to reduce duplication in modern app development without giving up the native experience on each platform. JetBrains positions KMP as a way to share code across Android, iOS, desktop, web, and server while still keeping the benefits of native development. In other words, you are not forced into a one-size-fits-all UI or a heavy abstraction layer just to reuse logic. You can start small, share only what makes sense, and grow from there.
That flexibility is what makes Kotlin Multiplatform feel different from the usual “cross-platform” story. Instead of trying to make every screen identical everywhere, KMP lets you share the parts that are expensive to duplicate: models, networking, validation, caching, business rules, and sometimes even UI through Compose Multiplatform. JetBrains’ Compose Multiplatform documentation describes it as a declarative framework for building shared UIs across Android, iOS, desktop, and web, while KMP itself remains the core code-sharing technology underneath.
If you have ever maintained the same API client twice, rewritten the same validation logic in two languages, or fixed a bug on iOS only to realize Android had the same issue in a slightly different shape, KMP starts to look less like a trend and more like relief. The most honest way to think about it is this: KMP does not erase platform differences; it helps you stop paying the same cost twice. That is especially valuable for teams that want native apps, native tooling, and a shared core that stays in sync.
What Kotlin Multiplatform actually gives you
At a high level, KMP lets you place shared code in a common module and platform-specific code in Android, iOS, and web targets. The common code can hold domain models, repositories, use cases, serialization, and state management, while each platform keeps its own UI or platform integrations if that better fits the product. JetBrains’ official guides and samples repeatedly show this pattern: shared networking, shared data storage abstractions, shared state, and native or shared UIs depending on the project.
That modularity matters because it changes the shape of your architecture. Instead of asking, “How do I make Android and iOS behave the same?” you start asking, “Which parts are truly business logic, and which parts belong to the platform?” That small shift usually leads to cleaner boundaries, fewer copy-paste bugs, and more confidence when you change something important. KMP is especially strong when your app depends on consistent rules, API behavior, and data transformation across platforms.
There is also an important distinction between Kotlin Multiplatform and Compose Multiplatform. KMP is the code-sharing foundation; Compose Multiplatform is an optional UI framework on top of it. JetBrains says Compose Multiplatform supports Android, iOS, desktop, and web, with web currently in Beta, while KMP itself is described as production-ready across its supported platforms. That distinction is worth remembering because it helps you choose the right strategy for the UI layer instead of assuming everything has to be shared.
The architecture that usually works best
A very common and very sensible setup is to keep three layers in the shared module:
The first layer is your domain layer: models, business rules, and use cases.
The second layer is your data layer: repositories, API clients, database access, and caching.
The third layer is your presentation state: view models, UI state, and mapping between data and screens.
That structure is not a law, but it is a good default because it makes the shared module useful without making it bloated. Official KMP samples and learning resources repeatedly highlight patterns like networking, data storage, shared ViewModels, coroutines, serialization, and dependency injection as the core building blocks of production-style projects.
In practical terms, your Android app might still use Jetpack Compose, your iOS app might still use SwiftUI, and your web app might use Compose Multiplatform or another web approach. The real gain comes from the fact that each screen can rely on the same source of truth for data and rules. That is where the “human” side of the architecture shows up: fewer arguments about whether logic is implemented the same way everywhere, fewer emergency fixes, and fewer hidden surprises during release week.
A simple project structure
A clean KMP project often looks like this:
root
├── shared
│ ├── src
│ │ ├── commonMain
│ │ ├── androidMain
│ │ ├── iosMain
│ │ └── wasmJsMain
├── androidApp
├── iosApp
└── webApp
The shared module is where most of the reusable logic lives. commonMain holds code used everywhere. Platform source sets like androidMain, iosMain, and wasmJsMain hold platform-specific implementations when needed. This kind of setup fits the official KMP idea of sharing code selectively rather than forcing every platform into one identical implementation.
The nice thing about this structure is that it scales gradually. A small team can start with just a few shared models and an API client. A larger team can grow that shared layer into a full domain and data stack. KMP is explicitly designed for incremental adoption, so you do not need to rewrite your existing Android app or redesign your iOS app all at once.
Setting up the shared module
A typical shared module uses the Kotlin Multiplatform plugin and declares the targets you want to support. The exact build script varies by version and project type, but the shape is usually similar to this:
plugins {
kotlin("multiplatform")
id("com.android.library")
kotlin("plugin.serialization")
}
kotlin {
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
wasmJs {
browser()
}
sourceSets {
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json")
implementation("io.ktor:ktor-client-core")
implementation("io.ktor:ktor-client-content-negotiation")
}
}
val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-okhttp")
}
}
val iosMain by getting {
dependencies {
implementation("io.ktor:ktor-client-darwin")
}
}
val wasmJsMain by getting {
dependencies {
implementation("io.ktor:ktor-client-js")
}
}
}
}
The important idea is not the exact syntax, but the separation of common and platform-specific dependencies. Official KMP guidance recommends starting from an IDE-supported setup and making sure the shared code is organized in a way that lets each platform plug in its own pieces when needed.
A good rule of thumb is to keep commonMain as small as possible at first, then expand only when the duplication becomes real pain. That keeps the codebase readable and prevents the shared layer from becoming a dumping ground for everything that is “not sure where to put.” In real projects, restraint is one of the best architecture decisions you can make.
Shared models: keep your data honest
The simplest win in KMP is shared data models. When your app talks to an API, the response shapes should not be redefined slightly differently in three places. Kotlin serialization is designed for converting application data to formats that can be transferred over a network or stored in a file, and KMP samples commonly use kotlinx-serialization as part of the shared stack.
Here is a straightforward example:
import kotlinx.serialization.Serializable
@Serializable
data class UserDto(
val id: String,
val name: String,
val email: String
)
And a domain model that your app actually uses:
data class User(
val id: String,
val displayName: String,
val email: String
)
Then a mapper:
fun UserDto.toDomain(): User {
return User(
id = id,
displayName = name.trim(),
email = email.lowercase()
)
}
This may look almost too simple, but it solves a real problem: the network layer can change without leaking raw DTO assumptions everywhere. That is one of the most underrated benefits of shared code. The more your app grows, the more valuable that boundary becomes.
Networking with Ktor
Ktor is a natural fit for Kotlin Multiplatform. Official Ktor documentation describes its client as a multiplatform asynchronous HTTP client that supports plugins such as authentication and JSON serialization. Ktor’s content negotiation plugin is used for serializing and deserializing content, and Ktor’s multiplatform guidance notes that plugin dependencies for multiplatform projects belong in commonMain.
A shared client might look like this:
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
fun createHttpClient(engine: HttpClientEngineFactory<*>): HttpClient {
return HttpClient(engine) {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
prettyPrint = false
isLenient = true
}
)
}
}
}
A repository can then use that client to fetch data:
import io.ktor.client.call.body
import io.ktor.client.request.get
class UserRepository(
private val client: HttpClient,
private val baseUrl: String
) {
suspend fun getUsers(): List<User> {
val response: List<UserDto> = client.get("$baseUrl/users").body()
return response.map { it.toDomain() }
}
}
This is one of the places where KMP feels especially elegant. The request logic, the serialization logic, the mapping logic, and the error handling can all live together in the shared module. The UI on each platform stays lightweight and focused on presentation. Ktor’s official docs and Kotlin’s own KMP tutorials both emphasize this style of shared network layer.
A repository that works on every platform
Once your client is in place, the repository becomes the perfect border between the outside world and your app. It can combine network data, local storage, and business rules before the UI ever sees them.
class FeedRepository(
private val api: FeedApi,
private val cache: FeedCache
) {
suspend fun loadFeed(): List<FeedItem> {
return try {
val remote = api.fetchFeed()
cache.save(remote)
remote
} catch (e: Exception) {
cache.load()
}
}
}
This pattern is boring in the best possible way. It makes the app easier to reason about. It lets you swap storage strategies later. It keeps the UI from knowing too much. And in a multiplatform project, it helps Android, iOS, and web all use the same rules for stale data, fallback behavior, and refresh logic. Official KMP samples show exactly this kind of shared data and network structure across multiple targets.
Local storage: why SQLDelight is a strong companion
For local persistence, JetBrains’ KMP tutorial on Ktor and SQLDelight shows a shared mobile app that retrieves data with Ktor and saves it locally with SQLDelight. That combination is popular for a reason: network access and data persistence are both common cross-platform concerns, and both benefit from being centralized.
A typical SQLDelight-style flow is:
class LaunchRepository(
private val service: LaunchApi,
private val database: LaunchDatabase
) {
suspend fun refreshLaunches(): List<Launch> {
val launches = service.getLaunches()
database.launchQueries.transaction {
database.launchQueries.deleteAll()
launches.forEach { launch ->
database.launchQueries.insertLaunch(
id = launch.id,
name = launch.name,
date = launch.date
)
}
}
return launches
}
}
Even if your project does not use SQLDelight specifically, the design lesson is still useful. Keep storage behind an interface. Let the shared layer define the rules. Let the platform only provide the driver or backing implementation when necessary. That is one of the most reliable ways to preserve flexibility as your app matures.
Shared state and view models
Many teams eventually want to share not just data access but also UI state and screen logic. Kotlin Multiplatform learning resources explicitly mention shared ViewModels, Koin-based dependency injection, and clean architecture as part of the real-world path for intermediate projects. That means the ecosystem is no longer limited to “just share DTOs and services”; you can share state management too when it makes sense.
A simple screen state holder can look like this:
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class UsersUiState(
val isLoading: Boolean = false,
val users: List<User> = emptyList(),
val error: String? = null
)
class UsersViewModel(
private val repository: UserRepository,
private val scope: CoroutineScope
) {
private val _state = MutableStateFlow(UsersUiState())
val state: StateFlow<UsersUiState> = _state
fun loadUsers() {
scope.launch {
_state.value = UsersUiState(isLoading = true)
try {
val users = repository.getUsers()
_state.value = UsersUiState(users = users)
} catch (e: Exception) {
_state.value = UsersUiState(error = e.message ?: "Unknown error")
}
}
}
}
This is the kind of code that pays for itself immediately. The UI on Android, iOS, and web can each observe the same state stream and render according to its own style. The business rules stay in one place, and your team avoids re-implementing screen logic three times in slightly different forms.
Android UI: still native, still familiar
One of the biggest myths about KMP is that it requires you to abandon native Android development. It does not. You can keep using Jetpack Compose or traditional Android UI while the shared module handles the heavy lifting. JetBrains’ KMP overview explicitly says that KMP focuses on code reuse without replacing native UI unless you want it to.
A Compose-based Android screen might look like this:
@Composable
fun UsersScreen(viewModel: UsersViewModel) {
val state by viewModel.state.collectAsState()
when {
state.isLoading -> {
Text("Loading...")
}
state.error != null -> {
Text("Error: ${state.error}")
}
else -> {
LazyColumn {
items(state.users) { user ->
Text(user.displayName)
}
}
}
}
}
The beauty here is that the Android layer remains small. It translates shared state into visuals. It does not need to know how the API client works, how the cache is filled, or how fallback logic is decided. That makes the Android app easier to evolve and easier to test.
iOS UI: native feel with shared logic underneath
On iOS, the common approach is to keep the UI native with SwiftUI while consuming the shared Kotlin module for logic and data. KMP’s core promise is that you can retain the advantages of native development while sharing code across platforms, and that is exactly where iOS teams tend to feel the benefit most strongly.
A SwiftUI screen can stay as simple as this in concept:
struct UsersScreen: View {
@StateObject var adapter: UsersViewModelAdapter
var body: some View {
VStack {
if adapter.isLoading {
Text("Loading...")
} else if let error = adapter.error {
Text("Error: \(error)")
} else {
List(adapter.users, id: \.id) { user in
Text(user.displayName)
}
}
}
}
}
The exact bridging code between Kotlin and Swift depends on your project setup, but the architectural idea is stable: SwiftUI handles presentation, while Kotlin handles shared logic. That is a strong compromise because it keeps the iOS app native to Apple users while reducing the cost of maintaining duplicated business rules.
Web support: what to know before you promise too much
The web story in KMP is exciting, but it deserves a little honesty. JetBrains’ current documentation says Kotlin/Wasm is the compilation target for web platforms, and Compose Multiplatform’s web target is currently in Beta. That means the web path is absolutely real, but it is not identical in maturity to the more established native targets.
A simple web entry point may look like this conceptually:
fun main() {
application {
App()
}
}
In a Compose Multiplatform web project, the UI is written in Kotlin and compiled to a browser-friendly target. The broader point is not that every web app should rush into KMP tomorrow, but that web can now participate in the same shared-code story instead of being a separate universe. That can be a huge advantage for product teams that want consistency across mobile and browser experiences.
For teams evaluating KMP seriously, it helps to distinguish between “shared business logic with a web frontend” and “fully shared UI on web.” The first is a conservative, often safer starting point. The second is more ambitious and very appealing, but because Compose Multiplatform web is still in Beta, it should be adopted with a clear understanding of tradeoffs.
expect/actual: a useful escape hatch
One of the most practical Kotlin Multiplatform tools is the expect / actual mechanism. It lets common code declare a platform-dependent API and each target provide its own implementation. That is especially helpful for things like device identifiers, file paths, secure storage, or platform-specific services. Official KMP materials emphasize retaining native capabilities while sharing as much logic as possible, and expect / actual is the clean way to do exactly that.
Example in common code:
expect fun platformName(): String
Android implementation:
actual fun platformName(): String = "Android"
iOS implementation:
actual fun platformName(): String = "iOS"
Web implementation:
actual fun platformName(): String = "Web"
Use this pattern sparingly and intentionally. The goal is not to scatter platform code everywhere. The goal is to keep the shared module clean while still making room for the few things that truly must differ by platform. That balance is what keeps a multiplatform architecture healthy over time.
Dependency injection in shared code
When a project grows, shared modules often need dependency injection. KMP learning resources explicitly mention Koin in real-world project stacks, and KMP sample projects also show common shared wiring for networking, storage, and view state. The exact DI framework is your choice, but the principle is consistent: create dependencies in one place and feed them into the shared architecture.
A simple service container can look like this:
class AppContainer(
private val client: HttpClient,
private val baseUrl: String
) {
val userRepository = UserRepository(client, baseUrl)
val feedRepository = FeedRepository(...)
}
This approach is a good fit for smaller and mid-sized apps because it keeps wiring explicit. There is less magic, fewer hidden edges, and fewer “why is this null on one platform?” moments. Later, if your application needs a more formal DI setup, you can adopt one without throwing away the architecture.
Testing shared code
One of the most underrated benefits of shared logic is testability. When the same business rules run on Android, iOS, and web, a single test suite can protect all of them at once. You are no longer forced to trust that three separate implementations stay behaviorally identical just because they share the same UI design. KMP guidance and samples emphasize production use cases built around shared models, networking, UI state, and data storage, which makes the shared test layer especially valuable.
A shared test could look like this:
class UserRepositoryTest {
@Test
fun `maps API data to domain correctly`() = runTest {
val repo = UserRepository(
client = FakeClient(),
baseUrl = "https://example.com"
)
val users = repo.getUsers()
assertEquals("Alice", users.first().displayName)
}
}
This kind of test gives you a lot of leverage. When you fix a mapping bug once, you fix it for every platform. That is the quiet but powerful promise of KMP: more confidence, less repetition, and fewer platform-specific surprises late in the cycle.
A realistic development workflow
A healthy KMP workflow usually follows this rhythm:
You start by building shared models and API access.
You add caching or local storage.
You move screen state into shared code where it helps.
You keep each platform’s UI focused on rendering and interaction.
You only share UI when the product and team both benefit from it.
That rhythm matters because it keeps the project from becoming overengineered. Not every app needs full shared UI. Not every app needs a web target from day one. Some teams will get tremendous value from a shared domain layer alone. Others will eventually expand into Compose Multiplatform for mobile and web. KMP is useful precisely because it lets the architecture evolve with the product instead of forcing a huge decision too early.
Where KMP shines most
Kotlin Multiplatform shines when these conditions are true:
You need native apps on Android and iOS.
You have meaningful shared business logic.
You want to reduce duplicated networking and data handling.
You care about code reuse without losing platform control.
You may want web support now or later, but you want to keep your options open.
It also shines for teams that are already comfortable with Kotlin. If your Android team speaks Kotlin fluently, moving shared logic into a multiplatform module is a very natural next step. If your organization already has a strong engineering culture and wants to unify data and rules across products, KMP can become one of the least painful ways to do it. JetBrains’ case studies and documentation both show production usage across mobile and beyond.
Where KMP may not be the best first move
KMP is powerful, but it is not automatically the right answer for every team. If your app is tiny, mostly static, or unlikely to share much logic, the setup cost may not be worth it. If your team has no Kotlin experience, the learning curve may slow you down more than the shared module helps at first. And if your web target needs highly custom, rapidly changing browser behavior, you may want to evaluate the current web approach carefully because Compose Multiplatform web is still in Beta.
That is not a weakness. It is just a reminder that good architecture is contextual. The best decision is not the most impressive one; it is the one that reduces real pain for your team. KMP does that exceptionally well when duplication is expensive and platform-specific UI still matters.
A practical example app idea
Imagine a simple product catalog app. The shared module handles:
The API client
The product models
The cache
The repository
The business rules for favorites and sorting
The screen state for loading, success, and error.
Android renders it with Jetpack Compose.
iOS renders it with SwiftUI.
Web renders it with Compose Multiplatform or a browser-oriented UI layer.
The result is not three unrelated apps. It is one product family with a shared brain and platform-appropriate faces. That is the phrase many teams eventually land on after living with KMP for a while, because it captures the real experience more accurately than “write once, run everywhere” ever did. KMP is not about removing platform identity; it is about making platform identity cheaper to maintain.
A small end-to-end example
Here is a simplified end-to-end flow that shows how the pieces fit together.
Shared DTO:
@Serializable
data class ProductDto(
val id: String,
val title: String,
val price: Double
)
Domain model:
data class Product(
val id: String,
val title: String,
val price: Double
)
API layer:
class ProductApi(private val client: HttpClient) {
suspend fun fetchProducts(): List<ProductDto> {
return client.get("https://example.com/products").body()
}
}
Repository:
class ProductRepository(private val api: ProductApi) {
suspend fun getProducts(): List<Product> {
return api.fetchProducts().map {
Product(
id = it.id,
title = it.title,
price = it.price
)
}
}
}
State:
data class ProductUiState(
val loading: Boolean = false,
val products: List<Product> = emptyList(),
val error: String? = null
)
Android or iOS UI then becomes a straightforward rendering layer that listens to state and displays the result. This is the essence of KMP in practice: shared logic first, shared presentation only when it genuinely helps. The official tutorials and sample projects reinforce exactly this layering.
The human side of Kotlin Multiplatform
There is a very practical emotional benefit to KMP that is easy to miss in technical discussions. It reduces friction. Teams spend less time asking, “Did we update both apps?” and more time asking, “Is this the right product behavior?” That may sound soft, but it is one of the reasons architecture matters so much. Good code organization changes how people work together.
KMP also changes the tone of collaboration. Android and iOS developers can still specialize deeply, but they no longer need to duplicate every important rule in isolation. Product managers get more consistent behavior across platforms. QA gets fewer mismatches. Backend teams have a clearer contract. That kind of alignment is not glamorous, but it is what makes a product feel reliable.
A good starting path for beginners
If you are new to Kotlin Multiplatform, the smartest route is not to begin with the biggest possible app. Start with a small feature and make it shared. JetBrains’ own quickstart and learning resources recommend beginning with an IDE-supported setup and building upward from there. That is also the least stressful path for a real team, because it lets you learn the platform gradually while still shipping useful work.
A beginner-friendly order looks like this:
Shared models
Shared networking
Shared repository
Shared screen state
Platform UI
Optional shared UI
Optional web target.
That order is practical because each step gives value on its own. You do not need to wait until the entire architecture is complete before the first benefit appears. In fact, the first benefit often arrives the moment you remove duplicated API and serialization code.
Final thoughts
Kotlin Multiplatform is compelling because it is not trying to erase platform reality. It accepts that Android, iOS, and web are different, then gives you a clean way to share the parts that should not be different. JetBrains’ official docs describe it as a production-ready approach for shared code across Android, iOS, desktop, web, and server, with Compose Multiplatform available when you want shared UI as well. That combination makes KMP one of the most balanced cross-platform strategies available today.
If your app has repeated business logic, repeated networking, repeated serialization, or repeated state handling, KMP can make your codebase calmer and your releases safer. If you also want the option to bring web into the same ecosystem, Kotlin/Wasm and Compose Multiplatform give you a modern path forward, while still leaving room for native UI where it matters most.
The best way to use Kotlin Multiplatform is not to force everything into the shared module. It is to share the right things, at the right pace, for the right reasons. That is where KMP stops being a technology choice and starts becoming a product advantage.