Taming the Web with Kotlin Multiplatform — How I actually built it
After mDevCamp in Prague, I had a few people ask the same thing: “The talk made the pitch, where’s the actual implementation?”
Taming the Web with Kotlin Multiplatform — How I actually built it

After mDevCamp in Prague, I had a few people ask the same thing: “The talk made the pitch, where’s the actual implementation?”
Fair question. This is the article version of the talk: same three-act flow, but with the code, the docs links, and the gotchas I hit while building Stockholm Transport, a real Kotlin Multiplatform SDK that ships to Android, iOS, JVM, Node, and the browser from one source tree.
If you saw the talk in Prague, the slides and the live demos are all on that landing page.
This article is for the people who didn’t make it — or who did, and want the references next to them when they sit down to try it themselves.
The premise — one library, multiple front doors
Mobile teams already solved “build it twice” with KMP for Android and iOS. The web is the third rebuild nobody talks about.
So I took an SL, Stockholm public transport — REST + WebSocket library, and pointed the same source tree at the browser too.
Everything lives in a single Gradle module called :stockholm-transport. The feature folders (lines, sites, departures, realtime, …) look like submodules, but are actually pulled into the same module via kotlin.srcDirs(...):
sourceSets {
commonMain {
kotlin.srcDirs(
"core/src/commonMain/kotlin",
"lines/src/commonMain/kotlin",
"realtime/src/commonMain/kotlin",
)
}
}
It’s not the only way to structure a KMP library; most people use separate Gradle subprojects, but it keeps the published artefact a single jar / klib / XCFramework / npm package, which is what consumers actually want.
The official multiplatform docs are at kotlinlang.org/docs/multiplatform.html; start with the source-set hierarchy chapter.
Act 1 — The Dream
Targets
The kotlin {} block declares targets. For a library that ships to Android, iOS, JVM, Node, and the browser:
kotlin {
androidTarget()
iosX64(); iosArm64(); iosSimulatorArm64()
jvm()
js(IR) {
browser()
nodejs()
useEsModules()
generateTypeScriptDefinitions()
binaries.library()
}
}
Two things worth calling out:
**binaries.library()**, not binaries.executable().
This was a correction from Artem Kobzar at JetBrains, for a publishable library, which library() is the canonical call.
The Kotlin/JS setup reference is kotlinlang.org/docs/js-project-setup.html.It
**generateTypeScriptDefinitions()** is the bit of magic that emits a .d.mts next to the .mjs bundle.
TypeScript consumers get autocompletion on every public Kotlin type you mark @JsExport.
The expect/actual seam
Everything in commonMain runs everywhere. When you need a platform specific — a logger writer, a default HTTP engine — you declare expect in common and actual in each platform source set:
// commonMain
expect fun platformLogWriter(): LogWriter
// jsMain
actual fun platformLogWriter(): LogWriter = ConsoleLogWriter()
// androidMain
actual fun platformLogWriter(): LogWriter = LogCatLogWriter()
In practice, you reach for expect/actual less than you’d think. Kotlin’s mature libraries (Ktor, kotlinx-serialization, kotlinx-coroutines) already ship platform-specific defaults; you just declare them in common, and the library wires the right engine at compile time.
Networking — Ktor, configured once
val client = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
coerceInputValues = true
})
}
install(WebSockets)
install(Logging) { logger = KtorLogger; level = LogLevel.INFO }
}
Two settings that matter for real-world APIs:
ignoreUnknownKeys = true— The SL API silently adds fields. Without this, every new field results in a deserialization crash across all platforms simultaneously.coerceInputValues = true— When the API returnsnullfor a field your DTO defaults tofalse, it falls back to the default instead of throwing.
Ktor docs: ktor.io. Serialization tuning: kotlinlang.org/api/kotlinx.serialization.
Errors are data
Each feature has a XxxRepositoryImpl(httpClient) returning a sealed DataResult<T> (Success or Error with a NetworkError), and a XxxViewModel exposing StateFlow<XxxUiState>.
Repositories never throw; ViewModels never throw. Ktor exceptions get translated into NetworkError at the repository edge.
This sounds like clean-architecture preaching, but the discipline pays off when you bind to React / SwiftUI / Compose; the contract is identical across platforms.
Loading, success, error, reload same state shape, same transitions.
Act 2 — The Reality
StateFlow doesn’t translate to JS the bridge
Kotlin coroutines have no native JS equivalent. Kotlin/JS can export StateFlow directly, but the resulting .d.mts drags in kotlin.coroutines.flow.StateFlow and a tree of standard-library internals, and consuming the flow forces the JS caller to manage CoroutineScope teardown by hand — which means leaks.
One method on the base ViewModel solves it:
abstract class BaseViewModel<S> {
@JsExport.Ignore
open val uiState: StateFlow<S> = ...
fun subscribe(onStateUpdate: (S) -> Unit) {
viewModelScope.launch {
uiState.collect { onStateUpdate(it) }
}
}
fun onCleared() { viewModelScope.cancel() }
}
JS gets a callback. Kotlin keeps the flow.
Compose, SwiftUI, JVM callers still do uiState.collectAsState().
One bridge, two worlds.
@JsExport.Ignore on uiState is the key: it hides the property from the generated TypeScript, so the JS surface stays clean. Annotation reference: kotlinlang.org/docs/js-to-kotlin-interop.html.
Where is this going next? Artem also previewed an upcoming Flow.asAsyncIterable() extension landing in kotlinx-coroutines.
When it ships, JS consumers will be able to write:
for await (const state of viewModel.uiState.asAsyncIterable()) { ... }
The callback contract still wins for setter-style frameworks (React’s useState), but AsyncIterable will become the idiomatic streaming path on the JS side.
Where coroutines actually run on JS
The question every web developer silently asks: “won’t Kotlin’s async system fight with React’s render cycle?” Short answer: no.
On JS, Dispatchers.Main is the microtask queue — the same queue JavaScript Promises use. When a StateFlow emits, internally it calls queueMicrotask() (a standard Web API, not a Kotlin one), your callback fires, React calls setState, React schedules a re-render. Mechanically identical to Promise.resolve().then(() => setState(...)). No competing scheduler. No frame-rate war.
The only caveat: if you mix a Kotlin flow with a raw JS Promise in the same handler, ordering between them is not guaranteed. Pick one inside any given chain.
The matrix shifts — check klibs.io
This is the slide that aged in my favor between writing and presenting.
The earlier version of the talk said: “Room can’t compile to JS; SQLDelight can.”
That was true through most of 2025.
Then Google shipped JS and Wasm support for Room.
So today, both work, and you pick the API ergonomics you prefer.
The rule that survives is the rule, not the example: check klibs.io before you commit any dependency.
Look for the JS or Wasm badge.
Every Apple platform, iOS, macOS, watchOS, tvOS, hides under one badge called “Kotlin/Native”; don’t read an empty JS column as “iOS unsupported.”
DataStore landed JS+Wasm in alpha.
The matrix moves quarterly. The discipline doesn’t.
Memory leaks — what to actually watch for
If JS consumers forget to callviewModel.onCleared(), the underlying viewModelScope keeps running and subscribe keeps emitting.
In React, that means setting state on an unmounted component.
The library can’t prevent it; the lifecycle hook has to come from the framework, but you can make it impossible to forget by wrapping it in a hook:
function useStockholmTransport<VM, S>(
factory: () => VM,
attach: (vm: VM, cb: (s: S) => void) => void,
) {
const [state, setState] = useState<S | null>(null)
useEffect(() => {
const vm = factory()
attach(vm, setState)
return () => (vm as any).onCleared()
}, [])
return state
}
That’s all the library boilerplate a React consumer ever writes. Every screen reuses it.
Act 3 — The Payoff (publishing)
Android — Maven
I use the vanniktech maven-publish plugin — it handles signing and Sonatype publishing in a few lines.
plugins { id("com.vanniktech.maven.publish") version "0.x" }
mavenPublishing {
publishToMavenCentral()
signAllPublications()
coordinates("com.umain.transport", "stockholm-transport", "1.0.0")
}
./gradlew :stockholm-transport:publishToMavenLocal puts the artefact in ~/.m2 for local testing; consumers add mavenLocal() to their repositories.
For Maven Central, the plugin docs cover the GPG signing setup.
iOS — XCFramework + Swift Package Manager
./gradlew :stockholm-transport:assembleStockholmTransportXCFramework
That produces a .xcframework an Xcode workspace can link directly, or you publish a Package.swift referencing it for SwiftPM consumers.
Kotlin maps suspend fun to Swift’s async throws automatically, which makes the consumer side feel native:
let result = try await api.linesViewModel.refresh()
npm — the polished package.json
This is where Kotlin/JS gets subtle.
The IR backend emits a .mjs bundle and a .d.mts next to it, but the auto-generated package.json is minimal , no exports map, no scoped name, no publishConfig.
You need to polish it.
The official way is the packageJson { customField(...) } DSL on the js(IR) block:
js(IR) {
binaries.library()
compilations["main"].packageJson {
val entry = "kotlin/StockholmTransport-stockholm-transport.mjs"
val types = "kotlin/StockholmTransport-stockholm-transport.d.mts"
customField("name", "@jacksonmafra-umain/stockholm-transport")
customField("main", entry)
customField("module", entry)
customField("types", types)
customField("exports", mapOf(
"." to mapOf(
"types" to "./$types",
"import" to "./$entry",
"default" to "./$entry",
),
"./package.json" to "./package.json",
))
customField("files", listOf("kotlin/", "README.md"))
customField("license", "Apache-2.0")
customField("publishConfig", mapOf(
"registry" to "https://npm.pkg.github.com",
"access" to "public",
))
}
}
Documented at kotlinlang.org/docs/js-project-setup.html#package-json-customization.
I had originally written a custom Gradle task that overwrote the auto-generated package.json after the build, also flagged by Artem as unnecessary.
The DSL handles it, the migration is one-for-one, and the task disappears.
Gotcha, I hit: I tried adding customField("type", "module") to make Node treat the package as ESM.
The Kotlin/JS pipeline emits a CommonJS webpack.config.js sibling inside the same package directory; declaring the whole package as ESM makes Node try to read that file as ESM too and fail.
Don’t add "type": "module".
The actual .tgz
A tiny Gradle task that packs the polished output into a real npm tarball:
tasks.register<Exec>("packTalkTgz") {
dependsOn("jsPublicPackageJson")
dependsOn("jsProductionExecutableCompileSync")
val packageDir = rootProject.layout.buildDirectory
.dir("js/packages/StockholmTransport-stockholm-transport").map { it.asFile }
val outputDir = rootProject.layout.buildDirectory.dir("distributions/npm")
workingDir = packageDir.get()
commandLine("npm", "pack", "--pack-destination", outputDir.get().asFile.absolutePath)
}
Output: build/distributions/npm/jacksonmafra-umain-stockholm-transport-1.0.0.tgz, 857 kB, 72 files.
That’s the artefact a registry would receive, and the one a consumer can npm install ./path/to.tgz directly from disk for end-to-end testing before you publish for real.
For GitHub Packages publishing, I use the org.danilopianini.npm.publish plugin pointed at https://npm.pkg.github.com with a personal access token (scopes: read:packages, write:packages).
Three rules I’d take home
If you take nothing else from this:
Export the behavior, hide the machinery. Your JS surface should look like a JS library, not a Kotlin library.
subscribe(callback) not StateFlow.
Promise not Deferred.
@JsExport.Ignore everything that isn’t part of the contract.
Coroutine scope is your lifecycle contract. Every ViewModel owns its scope; every consumer calls onCleared() when it unmounts. The library can’t enforce it — but the framework hook can. Make it impossible to forget.
The SDK boundary is the same on every platform. Repositories don’t throw; they return DataResult<T>. ViewModels expose StateFlow. Errors are data. When you do this consistently, the same code is naturally consumable from React, SwiftUI, Compose, Node — all of them.
Resources
- The project (slides, live demos, Q&A, source): **stockholm-transport.vercel.app**
- Kotlin Multiplatform: kotlinlang.org/docs/multiplatform.html
- Kotlin/JS project setup: kotlinlang.org/docs/js-project-setup.html
- JS interop reference: kotlinlang.org/docs/js-to-kotlin-interop.html
- Dependency badge matrix: klibs.io
- Ktor: ktor.io — Koin: insert-koin.io
Thanks again to Artem Kobzar and JetBrains for the support before and after the talk, to my colleagues at Umain for sitting through every rehearsal, and to Maria for keeping my React honest.
Prague was a great host.
Now back to building.
Děkuji.
메타데이터
- post_id
- dfa3147bdfa5
- slug
- taming-the-web-with-kotlin-multiplatform-how-i-actually-built-it-dfa3147bdfa5
- url
- https://medium.com/@jacksonfdam/taming-the-web-with-kotlin-multiplatform-how-i-actually-built-it-dfa3147bdfa5
- canonical_url
- https://medium.com/@jacksonfdam/taming-the-web-with-kotlin-multiplatform-how-i-actually-built-it-dfa3147bdfa5
- author_url
- https://medium.com/@jacksonfdam
- status
- ok
- fetched_at
- 2026-06-09 15:37:30