KMP: The Missing Introduction

Sep 18, 2026 6 min read

KMP - Distribution

The build is done. The library sits in build/. And no one else can use it.

The artifacts article opened the build and showed what it produces. This article does the next thing: it moves the library into other people’s builds. The project is the same greetings library from the earlier articles, at github.com/dushyant30suthar/KMP-Themissingintroduction. Every command and every output below is real, except where I say it is not.

1. One library, three doors

The kitchen state after the targets article: one module, four targets: linuxX64, jvm, js, wasmJs. The build produces a jar for the JVM, a klib (the Kotlin library format) for each of the others, and JavaScript and WebAssembly for the web.

Now the question: how does the library get into someone else’s build?

The answer splits by ecosystem. Each platform ecosystem keeps its own registry (a repository that stores packages and hands them out) and its own integration story:

My claim: publishing is not a fourth build. It takes the artifacts the build already produced, packs them into each ecosystem’s format, and gives them an address. The library does not change. The door does.

M-1

One library split across three registries: Maven Central, npm, and a SwiftPM git repository, each consumed by its own ecosystem

That is the whole map. The next three sections walk each door. The current build file declares no Apple target, so section 4 comes from the official docs, not from a run.

2. Maven: the JVM door

Maven names a package with coordinates: group:artifact:version. To make the Kotlin plugin produce Maven publications (the package units it registers in a repository), I added the maven-publish plugin and two values to greetings/build.gradle.kts:

kotlin
plugins {
    alias(libs.plugins.kotlinMultiplatform)
    // This article: publishing. The Kotlin plugin creates one publication
    // per target when maven-publish is applied.
    `maven-publish`
}

// Maven coordinates: group:artifact:version. The artifact is the project
// name, plus a suffix per target (greetings, greetings-jvm, greetings-js, ...).
group = "com.theemergentnarrative"
version = "1.0.0"

The plugin then creates one publication per target, plus a root publication that points at all of them. I ran the publish:

bash
./gradlew :greetings:publishToMavenLocal

It lands in the local Maven repository, ~/.m2/repository (trimmed from the real tree):

text
/home/dushyant30suthar/.m2/repository/com/theemergentnarrative/greetings/1.0.0/greetings-1.0.0.jar
/home/dushyant30suthar/.m2/repository/com/theemergentnarrative/greetings/1.0.0/greetings-1.0.0.module
/home/dushyant30suthar/.m2/repository/com/theemergentnarrative/greetings/1.0.0/greetings-1.0.0.pom
/home/dushyant30suthar/.m2/repository/com/theemergentnarrative/greetings-jvm/1.0.0/greetings-jvm-1.0.0.jar
/home/dushyant30suthar/.m2/repository/com/theemergentnarrative/greetings-jvm/1.0.0/greetings-jvm-1.0.0.pom
/home/dushyant30suthar/.m2/repository/com/theemergentnarrative/greetings-js/1.0.0/greetings-js-1.0.0.klib
/home/dushyant30suthar/.m2/repository/com/theemergentnarrative/greetings-linuxx64/1.0.0/greetings-linuxx64-1.0.0.klib
/home/dushyant30suthar/.m2/repository/com/theemergentnarrative/greetings-wasm-js/1.0.0/greetings-wasm-js-1.0.0.klib

Five publications: the root greetings (metadata that points at the rest) and one per target. The JVM one is a jar. Its POM (the file Maven reads) declares the coordinates and one dependency, kotlin-stdlib.

M-2

The Maven flow: publishToMavenLocal fills the local repository, and the consumer names one coordinate in its build file

Now the consumer side. I built a second project in scratch, a plain Kotlin JVM app, not a KMP project. Its build.gradle.kts, verbatim:

kotlin
plugins {
    kotlin("jvm") version "2.4.10"
    application
}

repositories {
    mavenLocal()
    mavenCentral()
}

dependencies {
    implementation("com.theemergentnarrative:greetings-jvm:1.0.0")
}

application {
    mainClass = "MainKt"
}

mavenLocal() is the ~/.m2 directory. mavenCentral() covers the transitive kotlin-stdlib. The first run failed without it: Could not find org.jetbrains.kotlin:kotlin-stdlib:2.4.10. The consumer’s main calls greet():

kotlin
import com.theemergentnarrative.kmpthemissingintroduction.greet

fun main() {
    println(greet())
}
text
> Task :run
Hello from Kotlin on JVM (Java Virtual Machine)

BUILD SUCCESSFUL in 6s

That is the whole of Maven integration: a repository and one line.

Public means Maven Central. The publisher registers a namespace (the group part of the coordinates), signs the artifacts with a PGP key (a key pair that proves who published them), uploads with a token, then releases from the portal. Central validates. Public availability takes 15 to 30 minutes.

Private means any other Maven repository: a Nexus or Artifactory server, GitHub Packages, a corporate intranet. The consumer adds the URL and credentials:

kotlin
maven {
    url = uri("https://your.secure.repo/url")
    credentials {
        username = "your-username"
        password = "your-password"
    }
}

The dependency line does not change. The address does.

3. NPM: the web door

The web lane ships an npm package: a directory with a package.json that describes it. The Gradle task that builds the production one:

bash
./gradlew :greetings:jsBrowserProductionLibraryDistribution

The real output, greetings/build/dist/js/productionLibrary/:

text
KMP-Themissingintroduction-greetings.js
KMP-Themissingintroduction-greetings.js.map
kotlin-kotlin-stdlib.js
kotlin-kotlin-stdlib.js.map
kotlin_org_jetbrains_kotlin_kotlin_dom_api_compat.js
kotlin_org_jetbrains_kotlin_kotlin_dom_api_compat.js.map
package.json

The package.json, verbatim:

json
{
  "name": "KMP-Themissingintroduction-greetings",
  "version": "1.0.0",
  "main": "KMP-Themissingintroduction-greetings.js",
  "devDependencies": {},
  "dependencies": {},
  "peerDependencies": {},
  "optionalDependencies": {},
  "bundledDependencies": []
}

The name is the root project name plus the module name. The wasmJs target builds the sibling package: the same shape, a .mjs module plus a .wasm binary.

Now the boundary. I required the built module in Node and looked for greet. It was not there. The module factory exported nothing. The built JS held greet as a local function, and the kitchen’s leftover root main() ran on load and printed a line. Nothing crosses from Kotlin into JS unless you mark it.

The annotation is @JsExport. In Kotlin 2.4 it is experimental, and the compiler says so (path trimmed):

text
w: file:///.../Greeting.kt:10:2 This declaration needs opt-in. Its usage should be marked with '@kotlin.js.ExperimentalJsExport' or '@OptIn(kotlin.js.ExperimentalJsExport::class)'

With the opt-in and the annotation on greet(), the rebuilt module carries an exports block. Real, from the minified file:

js
//region block: exports
function $jsExportAll$(_) {
  var com = _.com || (_.com = {});
  var theemergentnarrative = com.theemergentnarrative || (com.theemergentnarrative = {});
  var kmpthemissingintroduction = theemergentnarrative.kmpthemissingintroduction || (theemergentnarrative.kmpthemissingintroduction = {});
  kmpthemissingintroduction.greet = greet;
}
$jsExportAll$(_);
//endregion

Note the nesting: greet sits under the Kotlin package path, not at the top level.

The consumer, for real. I installed the package directory into a scratch project:

bash
npm install /home/dushyant30suthar/Projects/tennarrates/series/kmp-the-missing-introduction/kitchen/KMP-Themissingintroduction/.worktrees/kmp-distribution/greetings/build/dist/js/productionLibrary
text
added 1 package, and audited 3 packages in 745ms

found 0 vulnerabilities

npm records the dependency in the consumer’s package.json (a file: entry: the local stand-in for a registry package). A Node script calls the function:

js
const lib = require("KMP-Themissingintroduction-greetings");
const greet = lib.com.theemergentnarrative.kmpthemissingintroduction.greet;
console.log("consumer called greet():", greet());
text
Hello from Kotlin on Web (JavaScript, browser/Node)
consumer called greet(): Hello from Kotlin on Web (JavaScript, browser/Node)

The first line is the leftover main() again. The second is the consumer’s call.

Publishing to npm uses the official npm-publish Gradle plugin: configure the package name and version, then run ./gradlew :greetings:publishJsPackageToNpmjsRegistry with a token. For CI, npm’s Trusted Publishers (a CI authorization that needs no token) cover it. npm never republishes a version it has already taken.

Private on npm means a scope: @your-org/greetings. Scoped packages are private by default. Access goes to named collaborators or teams, and publishing needs a paid account, then npm publish or a staged publish that a maintainer approves.

M-3

The npm flow: the distribution task builds the package directory, and the consumer adds one dependency entry and one require

One entry in package.json, one require. That is the whole of npm integration.

4. SPM: the Apple door

The Apple lane ships differently. The KMP build assembles an XCFramework (the multi-platform binary package from the artifacts article). You zip it, upload the zip to a direct link (a GitHub release works), and write a Package.swift that points at the zip. The official template:

swift
// swift-tools-version:5.3
import PackageDescription

let package = Package(
   name: "Shared",
   platforms: [
     .iOS(.v14),
   ],
   products: [
      .library(name: "Shared", targets: ["Shared"])
   ],
   targets: [
      .binaryTarget(
         name: "Shared",
         url: "<link to the uploaded XCFramework ZIP file>",
         checksum:"<checksum calculated for the ZIP file>")
   ]
)

Two details do the work. The checksum (a fingerprint of the file) comes from swift package compute-checksum Shared.xcframework.zip. SwiftPM verifies it on download. The version is a git tag on the repository that holds Package.swift. The manifest lives in git. The binary lives in storage.

The consumer side: in Xcode, File > Add Package Dependencies, then the URL of the git repository. Apple’s docs are plain about public and private: “A package author can publish their Swift package to either public or private repositories. Xcode supports both private and publicly available packages.” Private means a private git repository and git credentials on the machine. The manifest and the import do not change.

M-4

The SwiftPM flow: the XCFramework is zipped and uploaded, a Package.swift with a binary target lives in a git repo, and Xcode resolves it by URL

Now the honest part. I did not run this lane. This host is Linux. It has no Swift toolchain, and the current kitchen build declares no Apple target, so no XCFramework can be assembled here. The zip, the checksum, the tag, and the Xcode resolve come from the official Kotlin and Apple docs, not from a run. needs human (macOS): run the flow end to end and replace the quotes with real output.

5. The ledger: what the consumer’s build file gains

Put the three doors side by side:

M-5

Ledger table: the one line each consumer's build file gains, per ecosystem

Three lines. Three registries. Three formats.

Public versus private, per ecosystem:

The line that names the library is the same in all cases. Distribution changes the address and the key, never the name.

The build produces the artifacts. Distribution gives them an address in each ecosystem’s registry. Three doors, one shape: an address, a package, one line in the consumer’s build file.