So far, our notes app runs on Android and iOS. The shared module handles business logic, networking, and database. The UI is platform-specific: Compose on Android, SwiftUI on iOS.
But KMP can go further. With Compose Multiplatform, you can build desktop apps for Windows, macOS, and Linux. With Kotlin/Wasm, you can run Kotlin in the browser. In this tutorial, we explore both options — what they look like, how to configure them, and when they make sense.
This is a conceptual tutorial. We will not actually build a desktop or web app from this project. Instead, we look at what the configuration would be and discuss when it makes sense to go beyond mobile.
Compose Multiplatform for Desktop
Compose Multiplatform is JetBrains’ framework that extends Jetpack Compose to multiple platforms. On Android, you already use it. On desktop, it works the same way — same Composables, same Modifier system, same layout model.
What It Looks Like
A desktop Compose app looks almost identical to an Android Compose app. The same @Composable functions work on both platforms:
// This Composable works on Android AND Desktop
@Composable
fun NoteCard(note: Note, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(note.title, style = MaterialTheme.typography.titleMedium)
Text(note.content, style = MaterialTheme.typography.bodyMedium)
}
}
}
The difference is in the entry point. On Android, you use setContent {} in an Activity. On Desktop, you use application {} and Window {}:
// desktopMain entry point
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "My Notes"
) {
App() // Same App composable as mobile
}
}
Adding a Desktop Target
To add desktop support to your KMP project, you need to:
- Add the
jvm("desktop")target in the shared module - Create a
desktopMainsource set - Create a desktop application module
Here is what the shared module’s build.gradle.kts would look like with a desktop target:
kotlin {
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true
}
}
// Desktop target
jvm("desktop")
sourceSets {
commonMain.dependencies {
// ... existing dependencies
}
androidMain.dependencies {
implementation(libs.sqldelight.android)
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.sqldelight.native)
implementation(libs.ktor.client.darwin)
}
// Desktop dependencies
val desktopMain by getting {
dependencies {
implementation(libs.sqldelight.jvm)
implementation(libs.ktor.client.okhttp)
}
}
}
}
The desktop target uses the JVM, so it shares the same SQLDelight JVM driver and Ktor OkHttp engine as Android. This means most of your code works on desktop without changes.
Desktop-Specific Considerations
Desktop apps have some differences from mobile:
| Feature | Mobile | Desktop |
|---|---|---|
| Window management | Single full-screen | Resizable windows, multiple windows |
| Input | Touch, gestures | Mouse, keyboard, trackpad |
| Navigation | Stack-based | Can use tabs, sidebar, or stack |
| System tray | Not applicable | System tray icon, menu |
| File access | Scoped storage | Full file system access |
| Notifications | Push notifications | System notifications |
For the notes app, the main changes would be:
- A sidebar for the note list instead of a full-screen list
- Keyboard shortcuts (Ctrl+S to save, Ctrl+N for new note)
- A larger default window size
- Menu bar integration
Platform-Specific UI with expect/actual
You can use expect/actual to provide different UI for different platforms:
// commonMain
@Composable
expect fun AppLayout(content: @Composable () -> Unit)
// androidMain
@Composable
actual fun AppLayout(content: @Composable () -> Unit) {
// Mobile layout — single column, full width
content()
}
// desktopMain
@Composable
actual fun AppLayout(content: @Composable () -> Unit) {
// Desktop layout — sidebar + content area
Row {
NoteListSidebar(modifier = Modifier.width(300.dp))
content()
}
}
Running the Desktop App
Once configured, you run the desktop app with:
./gradlew :composeApp:run
Or create a distributable package:
# Creates a native installer for the current OS
./gradlew :composeApp:packageDmg # macOS
./gradlew :composeApp:packageMsi # Windows
./gradlew :composeApp:packageDeb # Linux
Kotlin/Wasm for Web
Kotlin/Wasm (WebAssembly) lets you run Kotlin code in the browser. Combined with Compose Multiplatform for Web, you can build web UIs with the same Composables you use on mobile and desktop.
Current State
Kotlin/Wasm is in Alpha. It works, but there are some limitations:
- Performance — Wasm runs at near-native speed, but Compose rendering in the browser adds overhead
- Bundle size — the initial download is larger than a typical JavaScript app
- Browser support — requires browsers with Wasm GC support (Chrome 119+, Firefox 120+, Safari 18.2+)
- Ecosystem — not all Kotlin libraries support
wasmJsyet
For internal tools, prototypes, and apps where you already have a KMP codebase, it is a good option. For public-facing websites that need fast initial load times, consider using Kotlin/JS or a dedicated web framework.
Adding a Web Target
Here is what the configuration looks like:
kotlin {
// ... existing targets
// Web target
wasmJs {
browser {
commonWebpackConfig {
outputFileName = "app.js"
}
}
binaries.executable()
}
sourceSets {
// ... existing source sets
val wasmJsMain by getting {
dependencies {
implementation(libs.ktor.client.js)
}
}
}
}
The web target uses wasmJs — Kotlin compiled to WebAssembly, running in a JavaScript environment. The Ktor client uses the JS engine, which wraps the browser’s fetch API.
Web Entry Point
The web app’s entry point uses Compose for Web:
// wasmJsMain
fun main() {
CanvasBasedWindow(canvasElementId = "app-canvas") {
App() // Same App composable
}
}
And an HTML file to host it:
<!DOCTYPE html>
<html>
<head>
<title>My Notes</title>
</head>
<body>
<canvas id="app-canvas"></canvas>
<script src="app.js"></script>
</body>
</html>
Running the Web App
./gradlew :composeApp:wasmJsBrowserRun
This starts a development server and opens the app in your browser.
Web-Specific Considerations
| Feature | Mobile/Desktop | Web |
|---|---|---|
| Storage | SQLDelight (SQLite) | IndexedDB or localStorage |
| Networking | OkHttp / Darwin | Browser fetch API |
| File access | File system | File picker API |
| Notifications | Native push | Web Push API |
| Offline | Built-in | Service worker needed |
For the notes app, the biggest challenge on the web would be the database. SQLDelight does not have a browser driver. You would need to use a different storage solution:
- IndexedDB — the browser’s built-in database. You would write an
expect/actualdata source. - SQL.js — SQLite compiled to Wasm, running in the browser. Some KMP libraries support this.
- Server-side storage — skip local storage and use the API directly.
When to Go Beyond Mobile
Adding desktop and web targets is not free. Each target adds:
- Build time and complexity
- Platform-specific code for UI and system APIs
- Testing across more platforms
- New dependencies and potential compatibility issues
Desktop Makes Sense When
- Your users need the app on their computer (productivity apps, editors, tools)
- You already share UI with Compose Multiplatform
- The app is data-heavy and benefits from a larger screen
- You want a native desktop app, not just a website
Web Makes Sense When
- You need the app accessible in a browser without installation
- Internal tools that run on any device
- You already have the KMP shared module and want to reuse it
- The web version is a companion to the mobile app, not the primary product
Stay Mobile-Only When
- Your app is primarily a mobile experience (camera, GPS, sensors)
- Your team is small and adding platforms would slow down development
- The app relies heavily on platform-specific SDKs (ARKit, ML Kit, HealthKit)
- You want to ship fast and iterate
Project Structure with All Targets
If you were to add all targets, the project structure would look like this:
kmp-tutorial/
├── shared/
│ └── src/
│ ├── commonMain/ # Business logic, models, repository
│ ├── androidMain/ # Android-specific (SQLDelight driver, Ktor engine)
│ ├── iosMain/ # iOS-specific
│ ├── desktopMain/ # Desktop-specific (JVM SQLDelight driver)
│ └── wasmJsMain/ # Web-specific (IndexedDB, Ktor JS engine)
├── composeApp/
│ └── src/
│ ├── commonMain/ # Shared Compose UI
│ ├── androidMain/ # Android entry point
│ ├── desktopMain/ # Desktop entry point (Window)
│ └── wasmJsMain/ # Web entry point (CanvasBasedWindow)
├── iosApp/ # SwiftUI app
└── build.gradle.kts
The commonMain in composeApp would hold shared Composables (like NoteCard, NoteEditScreen). Platform-specific entry points and layouts live in their respective source sets.
The Commented Config in Our Project
In the tutorial-19-desktop-web branch, we added commented-out target declarations to the shared module. This shows you where the changes go without actually enabling them:
// Desktop target (KMP #19 — uncomment to enable)
// jvm("desktop")
// Web target (KMP #19 — uncomment to enable)
// wasmJs {
// browser()
// }
And the corresponding dependency blocks:
// Desktop dependencies (KMP #19 — uncomment with desktop target)
// val desktopMain by getting {
// dependencies {
// implementation(libs.sqldelight.jvm)
// implementation(libs.ktor.client.okhttp)
// }
// }
// Web dependencies (KMP #19 — uncomment with wasmJs target)
// val wasmJsMain by getting {
// dependencies {
// implementation(libs.ktor.client.js)
// }
// }
If you want to experiment with desktop or web targets, uncomment these sections and create the corresponding source set directories. The shared business logic in commonMain will work on all platforms without changes.
Code Sharing Summary
Here is how code sharing looks across all four platforms:
| Layer | Android | iOS | Desktop | Web |
|---|---|---|---|---|
| Business logic | shared | shared | shared | shared |
| Models | shared | shared | shared | shared |
| Networking (Ktor) | shared | shared | shared | shared |
| Database (SQLDelight) | shared | shared | shared | custom |
| ViewModel | shared | shared | shared | shared |
| UI framework | Compose | SwiftUI | Compose | Compose |
| Entry point | Activity | SwiftUI App | Window | Canvas |
The shared layer works everywhere. The UI layer is where platforms differ. With Compose Multiplatform, even the UI can be shared between Android, Desktop, and Web. Only iOS requires SwiftUI (or you can use Compose Multiplatform for iOS too, which is stable as of 2026).
Source Code
Full source code for this tutorial: GitHub — tutorial-19-desktop-web
What’s Next?
In the next tutorial, the final tutorial, we cover migrating an existing Android app to KMP — a step-by-step strategy for adding KMP to an app that was built for Android only.