I'm moving a small Kotlin Multiplatform project to production across 5 platforms. This is my experience so far.
Overall? Surprisingly positive. Web, Android, and iOS are live. Desktop and CLI are working locally, distribution coming soon. I recorded all my coding sessions and they are on YouTube.
The project is ongoing. Here's what actually broke along the way.
What's Working
| Platform | Status | Link |
|---|---|---|
| Web | Live | justusefuckingkotlin.com |
| Android | Beta | Play Store |
| iOS | TestFlight | Join Beta |
| Desktop | Local | Coming soon |
| CLI | Local | Coming soon |
Same Kotlin code compiles to WASM, JVM, and Native.
Stack
- Kotlin 2.3.0 + Compose Multiplatform 1.9.3
- GitHub Actions for CI/CD
- Cloudflare Pages (web)
- fastlane (iOS + Android)
- ktlint + detekt
Links
- Website: justusefuckingkotlin.com
- GitHub: adjorno/JUFK
- YouTube: Build sessions playlist
- X: @adjorno
9 Problems Nobody Warned Me About
1. macOS App Store rejects KMP apps
Apple requires every binary signed: your app, JVM runtime, Kotlin native libs, every .dylib. Each needs provisioning profiles. One unsigned file = instant rejection.
Our workaround: skip Mac App Store. Distribute via DMG (signed + notarized, simpler) in Github Releases.
2. WASM vs JS: Material Icons Extended breaks Webpack
Tried JS target for compatibility. Webpack couldn't optimize Material Icons Extended. Switched back to WASM.
If you need extended icons on web, stick with wasmJs.
3. detekt silently skips KMP code
./gradlew detekt passes but checks nothing. You need source-set-specific tasks:
./gradlew \
detektMetadataCommonMain \
detektAndroidMain \
detektIosArm64Main \
detektDesktopMain \
detektWasmJsMain
detektMetadataCommonMain is your shared code. Without it, CI is green while checking zero lines.
4. iOS fastlane version injection fails silently with GENERATE_INFOPLIST_FILE = true
When GENERATE_INFOPLIST_FILE is set to true in your KMP project, fastlane actions like increment_version_number have no effect because Xcode generates Info.plist dynamically.
Fix: Either set GENERATE_INFOPLIST_FILE = false in your Xcode project settings, or inject version properties directly via xcargs in your Fastfile:
build_app(
xcargs: [
"MARKETING_VERSION=#{sanitized_version}",
"CURRENT_PROJECT_VERSION=#{build_number}"
].join(" ")
)
5. Cloudflare Pages needs branch: main for tag deployments
When deploying from a git tag, Cloudflare doesn't know your branch. Without this, deployments go to preview URLs instead of production. This snippet was adapted from the official Cloudflare Workers CI/CD documentation for GitHub Actions: https://developers.cloudflare.com/workers/ci-cd/external-cicd/github-actions/
- name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy ./composeApp/build/dist/wasmJs/productionExecutable --project-name=${{ inputs.cloudflare_project_name }} --branch main # Required for tags/production deployments
6. Desktop packaging fails with version 0.x.y
DMG, MSI, PKG all require major version ≥ 1. And packageVersion must be set BEFORE targetFormats() — that call triggers validation immediately.
nativeDistributions {
val pkgVersion = if (version.startsWith("0.")) "1.0.0" else version
packageVersion = pkgVersion // Set first
targetFormats(Dmg, Msi, Deb) // Validates here
}
7. Windows build validates ALL desktop formats
createDistributable on Windows still validates DMG/PKG and fails. Use the platform-specific task:
# Wrong
run: ./gradlew createDistributable
# Right
run: ./gradlew packageMsi
8. Unicode arrows don't render on Compose Web
→ and ▸ show as boxes. Canvas rendering doesn't handle Unicode fonts well.
// Broken
Text("→")
// Works
Icon(Icons.AutoMirrored.Filled.ArrowForward, null)
Requires compose.materialIconsExtended dependency.
9. A surprising 3x CI speed boost from setup-gradle
I knew that caching dependencies with gradle/actions/setup-gradle would make CI faster. What I didn't expect was how much faster. For this KMP project, adding this single line cut the build time by nearly 3x, from over 6 minutes to just 2.
- uses: gradle/actions/setup-gradle@v4
It caches dependencies and build outputs. Without it, every CI run downloads everything fresh, which is especially painful with multi-platform artifacts.
KMP tooling is ready. These are edge cases, not blockers. If you've been waiting to try it — now's the time.
Top comments (0)