Master the art of building production-grade Kotlin Multiplatform applications with shared business logic across Android, iOS, and Next.js while maintaining native UI fidelity on each platform

Kotlin Multiplatform lets you share your Domain and Data layers (entities, use cases, repositories, and even ViewModels) across Android, iOS, and Next.js while each platform keeps a fully native UI. In this “Headless KMP” approach, you write business logic once in Kotlin and consume it from Jetpack Compose, SwiftUI, and React. This post walks through the full Clean Architecture setup: a feature-based multi-module Gradle project, Koin for dependency injection, Ktor and SQLDelight for data, SKIE for Swift interop, and @JsExport plus a FlowObserver bridge for TypeScript.
Introduction: The Convergence of Native Fidelity and Shared Logic
The software development industry has long oscillated between two extremes: the uncompromising quality of native development and the economic efficiency of cross-platform solutions. Historically, this presented a binary choice. Organizations could either maintain three distinct codebases (Android, iOS, and Web) tripling development costs and risking logic fragmentation or adopt “write once, run anywhere” frameworks like Flutter or React Native. While the latter offer velocity, they often impose non-native user interface rendering engines or the “uncanny valley” of emulated platform behaviors.
Kotlin Multiplatform (KMP) represents a paradigm shift in this landscape. Unlike its predecessors, KMP does not seek to unify the user interface. Instead, it operates on the philosophy of “Shared Business Logic, Native UI.” This approach acknowledges that while data parsing, authentication protocols, and analytical computations are platform-agnostic, the user’s interaction model the swipe gestures on iOS, the Material Design transitions on Android, and the DOM-based responsiveness of the Web should remain native to the target platform.
This article provides an exhaustive technical analysis and implementation guide for architecting a production-grade KMP application. It specifically addresses a complex, modern requirement: integrating a unified Kotlin core with Android (Jetpack Compose), iOS (SwiftUI), and Web (Next.js with TypeScript). This “Headless KMP” architecture leverages Kotlin for the Domain and Data layers while treating the Web not as a second-class citizen via WebAssembly canvas rendering, but as a first-class consumer of shared logic within the React ecosystem.
Why Adopt KMP in the Modern Stack?
The adoption of KMP is driven by the necessity to reduce “logic drift” the phenomenon where business rules subtly diverge across platforms over time due to disparate implementations. By centralizing the Domain layer, organizations ensure that a validation rule for a user’s password or the calculation of a financial total is identical across all touchpoints.
However, the integration of Next.js introduces unique challenges. Unlike mobile targets where Kotlin compiles to native binaries (JVM bytecode for Android, LLVM-based binaries for iOS), the Web target requires compiling Kotlin to JavaScript or WebAssembly. This necessitates a rigorous approach to interoperability, particularly when bridging the strongly typed, coroutine-based world of Kotlin with the asynchronous, promise-based ecosystem of TypeScript and React.
Architectural Philosophy: Clean Architecture in a Multiplatform World
Clean Architecture, conceptualized by Robert C. Martin, structures applications into concentric circles of responsibility. In a KMP context, this is not merely a design pattern but a mechanism for survival. The platform-specific layers (UI and Frameworks) must remain decoupled from the core logic to prevent platform pollution such as an iOS UIKit import leaking into shared business rules, which would break the Android and Web builds.
What Are the Layers of Abstraction?
The architecture proposed herein strictly adheres to the Dependency Rule: source code dependencies can only point inwards.
Layer | Component | Technology Stack | Responsibility |
|---|---|---|---|
Presentation (Outer) | UI / Views | Jetpack Compose, SwiftUI, React/Next.js | Rendering the state, capturing user input |
Presentation (Inner) | ViewModels / Presenters | Shared Kotlin (androidx.lifecycle.ViewModel) | State management, exposing StateFlow to UI |
Domain (Core) | Use Cases / Interactors | Pure Kotlin (Common) | Orchestrating business rules |
Domain (Core) | Entities | Pure Kotlin (Common) | Enterprise business objects |
Data (Inner) | Repositories | Shared Kotlin | Mediating between data sources |
Data (Outer) | Data Sources | Ktor, SQLDelight, DataStore | API access, local persistence |
The crucial evolution in this architecture is the movement of the ViewModel into the shared code. Historically, ViewModels were platform-specific. With the release of AndroidX Lifecycle 2.8.0, the ViewModel class and its viewModelScope are now multiplatform-compatible, allowing the state management logic to be written once and consumed by all three platforms.
Domain-Centric Design
The Domain layer is the most stable part of the application. In this architecture, it contains:
Entities are data classes representing the core business model. For Next.js interoperability, these must be carefully designed to export cleanly to JavaScript.
Repository Interfaces are contracts defining data operations, such as UserRepository.
Use Cases are single-responsibility classes that execute specific business actions, such as LoginUseCase.
By strictly defining Repository interfaces in the Domain layer, the application achieves “Inversion of Control.” The Data layer (outer) depends on the Domain layer (inner) to implement these interfaces, ensuring the business logic remains ignorant of whether the data comes from a SQL database, a REST API, or a mock object during testing.
Project Configuration: The Multi-Module Monorepo
A monolithic shared module often becomes a compilation bottleneck. As the application grows, a single change in a utility class could trigger the recompilation of the entire shared codebase. To mitigate this, a feature-based multi-module structure is imperative.
Directory Structure
The recommended structure separates the platform-specific application entry points from the shared logic modules:
Root Project
├── build-logic/ # Custom Gradle Convention Plugins
├── gradle/libs.versions.toml # Version Catalog
├── app-android/ # Native Android Application
├── app-ios/ # Native iOS Xcode Workspace
├── app-web/ # Next.js TypeScript Project
├── core/ # Shared Infrastructure
│ ├── common/ # Logger, Dispatchers, DateTime
│ ├── database/ # SQLDelight schema and drivers
│ ├── network/ # Ktor Client configuration
│ └── preferences/ # DataStore implementation
└── features/ # Shared Business Features
├── auth/
│ ├── domain/ # Pure Kotlin logic
│ ├── data/ # Repositories & API
│ └── ui/ # Shared ViewModels
└── feed/
└── ...
Gradle Version Catalog
Centralizing dependency versions is critical when coordinating the Kotlin toolchain across different platforms. Inconsistent versions of kotlinx-coroutines or ktor can lead to runtime crashes in the JavaScript target or binary incompatibilities on iOS.
# gradle/libs.versions.toml
[versions]
kotlin = "2.0.0"
agp = "8.4.0"
koin = "3.5.6"
ktor = "2.3.11"
coroutines = "1.8.1"
sqldelight = "2.0.2"
serialization = "1.6.3"
androidxLifecycle = "2.8.0"
skie = "0.8.2" # Critical for Swift interop
[libraries]
# Core Kotlin
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
# Networking
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
ktor-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
# Persistence
sqldelight-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
sqldelight-web = { module = "app.cash.sqldelight:web-worker-driver", version.ref = "sqldelight" }
# Architecture
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidxLifecycle" }
[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
skie = { id = "co.touchlab.skie", version.ref = "skie" }
Module Build Configuration
The build script for a shared feature module must explicitly target Android, iOS, and JavaScript. For Next.js integration, the js(IR) target is configured to generate TypeScript definitions. This is non-negotiable for maintaining type safety in the TypeScript codebase.
// features/auth/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.serialization)
alias(libs.plugins.skie) // Enhance Swift Interop
}
kotlin {
// 1. Android Target
androidTarget {
compilations.all {
kotlinOptions { jvmTarget = "17" }
}
}
// 2. iOS Targets
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "AuthFeature"
isStatic = true
}
}
// 3. JavaScript Target for Next.js
js(IR) {
moduleName = "auth-shared"
browser {
webpackTask {
mainOutputFileName = "auth-shared.js"
}
}
binaries.executable()
generateTypeScriptDefinitions()
}
sourceSets {
val commonMain by getting {
dependencies {
implementation(libs.coroutines.core)
implementation(libs.ktor.client.core)
implementation(libs.ktor.serialization)
implementation(libs.koin.core)
api(libs.androidx.lifecycle.viewmodel)
}
}
val androidMain by getting {
dependencies {
implementation(libs.ktor.client.android)
}
}
val iosMain by creating {
dependsOn(commonMain)
dependencies {
implementation(libs.ktor.client.darwin)
}
}
val jsMain by getting {
dependencies {
implementation(libs.ktor.client.js)
}
}
}
}
Domain Layer Implementation
The Domain layer represents the heart of your business logic. It must remain completely free of platform-specific dependencies.
Defining Entities
Entities should be simple data classes with serialization support for cross-platform compatibility:
// features/auth/domain/model/User.kt
package com.example.auth.domain.model
import kotlinx.serialization.Serializable
import kotlin.js.JsExport
@JsExport
@Serializable
data class User(
val id: String,
val email: String,
val displayName: String,
val avatarUrl: String?
)
@JsExport
@Serializable
data class AuthCredentials(
val email: String,
val password: String
)
The @JsExport annotation is essential for making these types accessible from TypeScript in the Next.js application.
Repository Interfaces
Define contracts that the Data layer must implement:
// features/auth/domain/repository/AuthRepository.kt
package com.example.auth.domain.repository
import com.example.auth.domain.model.User
import com.example.auth.domain.model.AuthCredentials
interface AuthRepository {
suspend fun login(credentials: AuthCredentials): Result<User>
suspend fun logout()
suspend fun getCurrentUser(): User?
suspend fun refreshToken(): Result<Unit>
}
Use Cases
Use Cases encapsulate single business operations:
// features/auth/domain/usecase/LoginUseCase.kt
package com.example.auth.domain.usecase
import com.example.auth.domain.model.AuthCredentials
import com.example.auth.domain.model.User
import com.example.auth.domain.repository.AuthRepository
class LoginUseCase(
private val authRepository: AuthRepository
) {
suspend operator fun invoke(
email: String,
password: String
): Result<User> {
// Validate input
if (email.isBlank()) {
return Result.failure(IllegalArgumentException("Email cannot be empty"))
}
if (password.length < 8) {
return Result.failure(IllegalArgumentException("Password must be at least 8 characters"))
}
val credentials = AuthCredentials(email, password)
return authRepository.login(credentials)
}
}
Presentation Layer: Shared ViewModels
With AndroidX Lifecycle 2.8.0, ViewModels can now live in shared code and expose state via StateFlow:
// features/auth/ui/AuthViewModel.kt
package com.example.auth.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.auth.domain.model.User
import com.example.auth.domain.usecase.LoginUseCase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.js.JsExport
@JsExport
sealed class AuthUiState {
object Idle : AuthUiState()
object Loading : AuthUiState()
data class Success(val user: User) : AuthUiState()
data class Error(val message: String) : AuthUiState()
}
@JsExport
class AuthViewModel(
private val loginUseCase: LoginUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<AuthUiState>(AuthUiState.Idle)
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
fun onLogin(email: String, password: String) {
viewModelScope.launch {
_uiState.value = AuthUiState.Loading
loginUseCase(email, password)
.onSuccess { user ->
_uiState.value = AuthUiState.Success(user)
}
.onFailure { error ->
_uiState.value = AuthUiState.Error(
error.message ?: "Unknown error occurred"
)
}
}
}
fun resetState() {
_uiState.value = AuthUiState.Idle
}
}
Data Layer Implementation
The Data layer implements the repository interfaces and handles platform-specific data operations.
API Service with Ktor
// features/auth/data/api/AuthApiService.kt
package com.example.auth.data.api
import com.example.auth.domain.model.AuthCredentials
import com.example.auth.domain.model.User
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class AuthApiService(
private val httpClient: HttpClient
) {
suspend fun login(credentials: AuthCredentials): User {
return httpClient.post("https://api.example.com/auth/login") {
contentType(ContentType.Application.Json)
setBody(credentials)
}.body()
}
suspend fun getCurrentUser(): User? {
return try {
httpClient.get("https://api.example.com/auth/me").body()
} catch (e: Exception) {
null
}
}
}
Repository Implementation
// features/auth/data/repository/AuthRepositoryImpl.kt
package com.example.auth.data.repository
import com.example.auth.data.api.AuthApiService
import com.example.auth.domain.model.AuthCredentials
import com.example.auth.domain.model.User
import com.example.auth.domain.repository.AuthRepository
class AuthRepositoryImpl(
private val apiService: AuthApiService
) : AuthRepository {
override suspend fun login(credentials: AuthCredentials): Result<User> {
return try {
val user = apiService.login(credentials)
Result.success(user)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun logout() {
// Clear tokens and cached data
}
override suspend fun getCurrentUser(): User? {
return apiService.getCurrentUser()
}
override suspend fun refreshToken(): Result<Unit> {
// Implement token refresh logic
return Result.success(Unit)
}
}
Dependency Injection with Koin
Koin provides a lightweight, multiplatform-compatible DI solution:
// core/common/di/AppModule.kt
package com.example.di
import com.example.auth.data.api.AuthApiService
import com.example.auth.data.repository.AuthRepositoryImpl
import com.example.auth.domain.repository.AuthRepository
import com.example.auth.domain.usecase.LoginUseCase
import com.example.auth.presentation.AuthViewModel
import io.ktor.client.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import org.koin.core.module.dsl.factoryOf
import org.koin.dsl.module
val networkModule = module {
single {
HttpClient {
install(ContentNegotiation) {
json()
}
}
}
}
val authModule = module {
single { AuthApiService(get()) }
single<AuthRepository> { AuthRepositoryImpl(get()) }
factory { LoginUseCase(get()) }
factory { AuthViewModel(get()) }
}
val appModules = listOf(networkModule, authModule)
For JavaScript consumption, create a helper object:
// core/common/di/KoinHelper.kt
package com.example.di
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.core.context.startKoin
import kotlin.js.JsExport
import com.example.auth.presentation.AuthViewModel
@JsExport
object KoinHelper : KoinComponent {
fun initKoin() {
startKoin {
modules(appModules)
}
}
fun getAuthViewModel(): AuthViewModel {
val viewModel: AuthViewModel by inject()
return viewModel
}
}
Platform Integration: Android with Jetpack Compose
Android integration is the most straightforward since KMP was designed with Android as a primary target:
// app-android/src/main/java/com/example/android/ui/LoginScreen.kt
package com.example.android.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.auth.presentation.AuthUiState
import com.example.auth.presentation.AuthViewModel
import org.koin.androidx.compose.koinViewModel
@Composable
fun LoginScreen(
viewModel: AuthViewModel = koinViewModel(),
onLoginSuccess: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
LaunchedEffect(uiState) {
if (uiState is AuthUiState.Success) {
onLoginSuccess()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { viewModel.onLogin(email, password) },
enabled = uiState !is AuthUiState.Loading,
modifier = Modifier.fillMaxWidth()
) {
if (uiState is AuthUiState.Loading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
Text("Login")
}
}
if (uiState is AuthUiState.Error) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = (uiState as AuthUiState.Error).message,
color = MaterialTheme.colorScheme.error
)
}
}
}
Platform Integration: iOS with SwiftUI
For iOS, the SKIE plugin transforms Kotlin’s StateFlow into native Swift async sequences:
// app-ios/Sources/Features/Auth/LoginView.swift
import SwiftUI
import AuthFeature // The KMP framework
struct LoginView: View {
@StateObject private var viewModel = AuthViewModelWrapper()
@State private var email = ""
@State private var password = ""
var body: some View {
VStack(spacing: 16) {
TextField("Email", text: $email)
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
SecureField("Password", text: $password)
.textFieldStyle(.roundedBorder)
Button(action: {
viewModel.login(email: email, password: password)
}) {
if viewModel.isLoading {
ProgressView()
} else {
Text("Login")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading)
if let error = viewModel.errorMessage {
Text(error)
.foregroundColor(.red)
.font(.caption)
}
}
.padding()
}
}
@MainActor
class AuthViewModelWrapper: ObservableObject {
private let viewModel: AuthViewModel
@Published var isLoading = false
@Published var user: User? = nil
@Published var errorMessage: String? = nil
init() {
self.viewModel = KoinHelper.shared.getAuthViewModel()
observeState()
}
private func observeState() {
Task {
// SKIE converts StateFlow to AsyncSequence
for await state in viewModel.uiState {
switch state {
case is AuthUiState.Idle:
isLoading = false
errorMessage = nil
case is AuthUiState.Loading:
isLoading = true
errorMessage = nil
case let success as AuthUiState.Success:
isLoading = false
user = success.user
case let error as AuthUiState.Error:
isLoading = false
errorMessage = error.message
default:
break
}
}
}
}
func login(email: String, password: String) {
viewModel.onLogin(email: email, password: password)
}
}
Platform Integration: Next.js with TypeScript
The Web integration requires bridging Kotlin’s reactive streams to React’s state management:
Flow Observer Utility
Create a utility in Kotlin to help observe StateFlow from JavaScript:
// core/common/util/FlowObserver.kt
package com.example.util
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.StateFlow
import kotlin.js.JsExport
@JsExport
class FlowObserver<T : Any>(
private val flow: StateFlow<T>
) {
private var scope: CoroutineScope? = null
fun start(callback: (T) -> Unit) {
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
scope?.launch {
flow.collect { value ->
callback(value)
}
}
}
fun stop() {
scope?.cancel()
scope = null
}
}
React Custom Hook
// app-web/src/hooks/useAuthViewModel.ts
'use client';
import { useState, useEffect, useRef } from 'react';
import { com } from 'auth-shared';
// Alias types for clarity
type AuthViewModel = com.example.auth.presentation.AuthViewModel;
type AuthUiState = com.example.auth.presentation.AuthUiState;
const KoinHelper = com.example.di.KoinHelper;
export const useAuthViewModel = () => {
// Initialize ViewModel once
const viewModelRef = useRef<AuthViewModel | null>(null);
if (!viewModelRef.current) {
viewModelRef.current = KoinHelper.getAuthViewModel();
}
const viewModel = viewModelRef.current;
const [state, setState] = useState<AuthUiState>(viewModel.uiState.value);
useEffect(() => {
// Create the observer
const flowObserver = new com.example.util.FlowObserver(viewModel.uiState);
flowObserver.start((newState: AuthUiState) => {
setState(newState);
});
// Cleanup on unmount
return () => {
flowObserver.stop();
};
}, [viewModel]);
return {
state,
login: (email: string, password: string) => viewModel.onLogin(email, password),
resetState: () => viewModel.resetState()
};
};
Next.js Page Component
A critical insight for Next.js integration is SSR Safety. The Kotlin/JS generated code executes in the JavaScript runtime. If the KMP logic attempts to access browser-specific APIs during the server-side render pass, the application will crash.
// app-web/src/app/login/page.tsx
'use client'; // Mandatory for KMP integration
import { useAuthViewModel } from '@/hooks/useAuthViewModel';
import { useState } from 'react';
import { com } from 'auth-shared';
export default function LoginPage() {
const { state, login } = useAuthViewModel();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
// Type guards for Kotlin Sealed Classes in JS
const isLoading = state instanceof com.example.auth.presentation.AuthUiState.Loading;
const isSuccess = (s: any): s is { user: { displayName: string } } => {
return s instanceof com.example.auth.presentation.AuthUiState.Success;
};
const isError = (s: any): s is { message: string } => {
return s instanceof com.example.auth.presentation.AuthUiState.Error;
};
if (isSuccess(state)) {
return (
<main className="flex min-h-screen flex-col items-center justify-center p-24">
<h1 className="text-4xl font-bold">
Welcome, {state.user.displayName}!
</h1>
</main>
);
}
return (
<main className="flex min-h-screen flex-col items-center justify-center p-24">
<form
className="flex flex-col gap-4 w-full max-w-md"
onSubmit={(e) => {
e.preventDefault();
login(email, password);
}}
>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="px-4 py-2 border rounded"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="px-4 py-2 border rounded"
/>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
>
{isLoading ? 'Loading...' : 'Login'}
</button>
{isError(state) && (
<p className="text-red-500 text-sm">{state.message}</p>
)}
</form>
</main>
);
}
How Do You Handle SSR Safety with KMP and Next.js?
When integrating KMP with Next.js, you must consider server-side rendering compatibility:
‘use client’ Directive: All components consuming the KMP hooks must be marked with 'use client' to ensure they run primarily in the browser context.
Lazy Loading: In jsMain, ensure that accessing browser APIs is done lazily or guarded by checks for typeof window !== 'undefined'.
Dynamic Imports: For heavy KMP modules, use next/dynamic with ssr: false to disable server-rendering for those specific component trees.
Pros and Trade-offs Analysis
Adopting this architecture is a significant investment. Below is a critical analysis of the implications.
Comparative Advantage Analysis
Feature | React Native / Flutter | KMP (Headless) | Analysis |
|---|---|---|---|
UI Rendering | Custom Engine / Native Bridge | 100% Native | KMP offers superior UI fidelity |
Code Sharing | ~90% (UI + Logic) | ~60% (Logic only) | KMP shares less, but shares the most complex parts |
Web Support | Canvas (Flutter) or DOM (RN Web) | Native DOM (React) | KMP allows standard Next.js features |
Performance | Bridge Overhead | Near Native | Kotlin compiles to native binaries on mobile |
Trade-offs
Complexity of Build Pipeline: Maintaining a Gradle build that correctly outputs Android AARs, iOS Frameworks, and NPM packages is complex. It requires platform engineering knowledge. Debugging Gradle task dependencies across three ecosystems is non-trivial.
The Mental Context Switching Cost: Developers must understand Kotlin for logic, Swift for iOS UI, and TypeScript for Web UI. This is a higher cognitive load than a pure TypeScript stack. However, it prevents the “lowest common denominator” UI problem.
Kotlin/JS Bundle Size: Even with Dead Code Elimination, including the Kotlin Standard Library and Coroutines in the JS bundle adds weight (typically around 100KB gzipped). This is acceptable for complex web apps but may be excessive for simple landing pages.
Interop Friction: While SKIE improves iOS interop significantly, and @JsExport helps Web, there are edge cases. Kotlin generics, Long types, and sealed classes require specific handling to feel idiomatic in Swift and TypeScript.
Key Takeaways
The architecture presented in this article Clean Architecture backed by Kotlin Multiplatform, utilizing Koin for injection, SQLDelight for data, and Next.js for the web represents a mature, production-ready approach for modern application development.
It solves the “Logic Drift” problem without compromising the user experience. By treating the KMP core as an internal SDK, organizations can empower their Android, iOS, and Web teams to focus on what they do best: building amazing user interfaces, while relying on a rock-solid, unified foundation for business rules and data management.
Embrace Modules: Do not build a monolith. Use feature modules to keep build times low and boundaries strict.
Invest in Tooling: Use Version Catalogs and Convention Plugins to manage the build complexity.
Automate Web Integration: Script the copying of JS artifacts to the Next.js project to streamline the developer loop.
Guard Boundaries: Use strict DTOs and mappers at the Domain edge to ensure that platform-specific nuances (like JS Numbers) do not leak into the core logic.
This blueprint offers a sustainable path forward, balancing the efficiency of code reuse with the imperative of high-quality, platform-native experiences.
Frequently Asked Questions
What does KMP actually share across platforms? It shares the Domain and Data layers, plus the Presentation layer’s ViewModels. That means entities, use cases, repository interfaces and implementations, networking with Ktor, and state management all live in Kotlin. The UI stays native on each platform: Jetpack Compose on Android, SwiftUI on iOS, and React/Next.js on the Web. In this Headless KMP model you share roughly 60% of the code, but it is the most complex 60%: the business logic where “logic drift” usually creeps in.
Can ViewModels really live in shared Kotlin code? Yes. With AndroidX Lifecycle 2.8.0, the ViewModel class and its viewModelScope became multiplatform-compatible, so you can write state management once and consume it from all three platforms. The AuthViewModel in this post exposes a StateFlow of AuthUiState, which Android collects with collectAsState, iOS observes as an async sequence via SKIE, and Next.js reads through the FlowObserver bridge.
How do you consume Kotlin state from a Next.js app?
You annotate the types you need with @JsExport so they are visible from TypeScript, then bridge Kotlin’s StateFlow into React state. The FlowObserver utility wraps a StateFlow and calls a JavaScript callback on each emission, and the useAuthViewModel hook starts it on mount and stops it on unmount. Any component consuming these hooks must be marked with 'use client', because the generated Kotlin/JS code needs the browser runtime and will crash if it runs during the server-side render pass.
What are the main trade-offs of this architecture? The build pipeline is complex, since one Gradle setup has to output Android AARs, iOS Frameworks, and an NPM package. There is a mental context-switching cost, because developers work in Kotlin for logic, Swift for iOS UI, and TypeScript for Web UI. The Kotlin/JS bundle adds weight (typically around 100KB gzipped) even after Dead Code Elimination. And interop has edge cases: Kotlin generics, Long types, and sealed classes need specific handling to feel idiomatic in Swift and TypeScript.
Happy coding!
David Cruz
davthecoder.com
paglipat.com
Loading comments…