Skip to content
davthecoder
The Unified Codebase: A Comprehensive Blueprint for Kotlin Multiplatform Clean Architecture
tech

The Unified Codebase: A Comprehensive Blueprint for Kotlin Multiplatform Clean Architecture

By David Cruz Anaya, Independent Senior Mobile Engineer (Android, Kotlin Multiplatform, Rust)

Updated 11 July 2026
AndroidKotlinKMPiOSNext.jsClean ArchitectureMobile Development
Share:

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

The Unified Codebase Kotlin Multiplatform

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

Share:

Comments

Loading comments…