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:

  1. Add the jvm("desktop") target in the shared module
  2. Create a desktopMain source set
  3. 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:

FeatureMobileDesktop
Window managementSingle full-screenResizable windows, multiple windows
InputTouch, gesturesMouse, keyboard, trackpad
NavigationStack-basedCan use tabs, sidebar, or stack
System trayNot applicableSystem tray icon, menu
File accessScoped storageFull file system access
NotificationsPush notificationsSystem 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 wasmJs yet

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

FeatureMobile/DesktopWeb
StorageSQLDelight (SQLite)IndexedDB or localStorage
NetworkingOkHttp / DarwinBrowser fetch API
File accessFile systemFile picker API
NotificationsNative pushWeb Push API
OfflineBuilt-inService 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/actual data 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:

LayerAndroidiOSDesktopWeb
Business logicsharedsharedsharedshared
Modelssharedsharedsharedshared
Networking (Ktor)sharedsharedsharedshared
Database (SQLDelight)sharedsharedsharedcustom
ViewModelsharedsharedsharedshared
UI frameworkComposeSwiftUIComposeCompose
Entry pointActivitySwiftUI AppWindowCanvas

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.