
Kotlin Multiplatform enables 40-95% code sharing between platforms while avoiding the pitfalls of full cross-platform UI frameworks and major companies like Cash App, McDonald’s, and Netflix have proven this architecture in production. For Android developers building web applications alongside mobile apps, the optimal pattern emerging from real-world experience combines KMP for shared business logic, Ktor for backend services, Supabase for authentication and data, and Next.js for web UI rather than Compose Multiplatform for Web. This hybrid approach delivers platform-native experiences while maximizing code reuse where it matters most: in business logic, networking, and data management.
The key insight driving this architecture is that Compose Multiplatform for Web renders to an opaque canvas, making it fundamentally unsuitable for public-facing websites requiring SEO and accessibility. Meanwhile, the security challenges of proxying Supabase authentication through a Ktor server argue strongly for client-side auth with server-side JWT validation not a full auth proxy.
Why Compose Multiplatform for Web isn’t ready for production websites
Compose Multiplatform for Web reached Beta status in August 2025, but its canvas-based rendering architecture creates insurmountable problems for public-facing websites. Unlike traditional web frameworks that render to the DOM, Compose Web draws everything onto a single HTML5 canvas element. Search engine crawlers see only <canvas id="ComposeTarget"></canvas> with no indexable content zero SEO capability.
The accessibility situation is equally problematic. The W3C notes that canvas content “is not accessible to screen readers because the content is not in the DOM and has no accessibility semantics.” Screen readers like JAWS, NVDA, and VoiceOver cannot interpret canvas-rendered content. Users cannot select or copy text. Browser DevTools become useless for layout inspection. While JetBrains added basic accessibility support in version 1.9.0-beta06, the fundamental canvas architecture makes WCAG compliance structurally difficult.
Bundle size delivers the final blow for web performance. A typical Compose Web application weighs approximately 5MB including the Skia rendering engine (Skiko) at 2-3MB plus application code. Compare this to Next.js at 79-87KB base or React at 42KB. On 3G networks, 5MB means 10+ seconds of blank screen before any content appears, with no server-side rendering to provide initial HTML. Flutter Web, also canvas-based, explicitly states it is “not trying to solve accessibility” for web, suggesting these are inherent limitations of the approach rather than temporary gaps.
Compose Multiplatform for Web makes sense for internal tools, SaaS dashboards behind login screens, or web versions of existing mobile apps where users are already invested. For a public-facing website like Paglipat.com requiring discoverability, accessibility, and performance, Next.js is the clearly superior choice.
Separating authentication from your data API solves the Supabase-Ktor security puzzle
The cleanest architecture for hybrid Supabase-Ktor applications separates concerns: Supabase.js handles authentication on the client, while Ktor validates JWTs without managing auth state. Attempting to proxy Supabase authentication through Ktor introduces significant complexity around JWT verification, token refresh synchronization, and session management.
The proxy approach fails because Supabase uses single-use refresh tokens with automatic rotation. When Ktor sits between client and Supabase, race conditions emerge the client may hold a stale token while the server has refreshed, causing “refresh token already used” errors. Supabase’s documentation explicitly warns that “the refresh token sent from browser to server is likely stale.” The SDK handles this automatically when authentication happens client-side.
For Ktor server JWT verification, configure the ktor-server-auth-jwt plugin to validate Supabase tokens directly. Supabase JWTs use HS256 with a shared secret by default, with the issuer at https://your-project.supabase.co/auth/v1 and audience set to authenticated. Extract user claims from the validated token sub for user ID, email for email address, role for authorization level to make access control decisions without ever managing authentication state.
install(Authentication) {
jwt("auth-supabase") {
verifier(JWT.require(Algorithm.HMAC256(jwtSecret))
.withAudience("authenticated")
.withIssuer(jwtIssuer)
.build())
validate { credential ->
if (credential.payload.getClaim("sub").asString().isNotEmpty()) {
JWTPrincipal(credential.payload)
} else null
}
}
}
Row Level Security implications matter critically here. Supabase’s service_role key completely bypasses RLS policies, granting unrestricted database access. This key should only be used for server-side webhooks, admin operations, background jobs, and migrations never for user-facing endpoints. For user-scoped operations from Ktor, either forward the user’s JWT to Supabase (maintaining RLS enforcement) or implement equivalent authorization logic in Ktor itself.
The anon key is safe for browser/mobile exposure because access is controlled by RLS policies. Supabase is transitioning to new key formats (sb_publishable_... and sb_secret_...) enabling independent rotation. Always configure CORS with specific domain whitelists rather than wildcards, including apikey and authorization in allowed headers.
KMP dependency management requires platform-aware configuration throughout
Managing Ktor Client, Koin, kotlinx-serialization, kotlinx-datetime, and SQLDelight in KMP projects involves three layers of configuration complexity: shared commonMain dependencies, platform-specific implementations via expect/actual patterns, and Gradle source set orchestration.
Ktor Client requires different engines per platform: OkHttp for Android, Darwin for iOS, CIO or Apache for JVM. Each engine has distinct capabilities HTTP/2 support varies, SSL configuration differs, WebSocket behavior changes. The expect/actual pattern elegantly abstracts this, letting shared code use a createHttpClient() function while platform modules provide appropriate implementations. Configuration complexity compounds when adding content negotiation, logging, and timeout plugins.
Koin dependency injection for KMP uses three key patterns. First, expect/actual declarations for platform-specific dependencies like Android Context or iOS-specific APIs. Second, interface definitions in commonMain with platform implementations for testability. Third, explicit initialization from platform entry points Android’s Application class and iOS’s Swift AppDelegate must both call initKoin(). Memory model improvements in Kotlin 1.7.20+ simplified this significantly, but Android Context injection still requires explicit androidContext() setup.
kotlinx-datetime exists because Java 8 time APIs aren’t available in commonMain. The library provides cross-platform date handling but lacks built-in formatting parsing and formatting require platform-specific code using DateTimeFormatter on Android and NSDateFormatter on iOS. The community-maintained kotlinx-datetime-ext library fills gaps like YearMonth support and parsing utilities.
SQLDelight setup involves driver configuration per platform: AndroidSqliteDriver, NativeSqliteDriver for iOS, and JdbcSqliteDriver for JVM. iOS requires the -lsqlite3 linker flag. Schema files must live in commonMain. Migration files automatically determine version numbers. Windows migration verification has known issues with sqlitejdbc. Destructive migration requires custom SqlSchema implementation rather than built-in support.
Binary size impact accumulates significantly. Pure KMP shared code adds hundreds of KB to iOS apps. Adding Ktor and SQLDelight can push this to 6-19MB. Compose Multiplatform adds approximately 9MB for the Skia graphics library. Version catalog setup (libs.versions.toml) centralizes version management, while Gradle convention plugins reduce duplication across multi-module projects. Enable Gradle configuration cache, build cache, and parallel execution to mitigate build time impacts.
React and Next.js patterns mirror Compose development for Android developers
Android developers familiar with Jetpack Compose find React surprisingly intuitive because both frameworks share a declarative UI paradigm, functional component architecture, and similar state management patterns. The mental model of “describe what the UI should look like based on current state” translates directly between frameworks.
State management shows the clearest parallel. React’s useState(initialValue) mirrors Compose’s remember { mutableStateOf(initialValue) } both create state that triggers UI updates when changed. React’s useMemo(compute, [deps]) corresponds to remember(keys) { compute() }. The state hoisting pattern lifting state to common ancestors, passing values down as props/parameters, and events up as callbacks works identically in both frameworks.
Side effect handling in React consolidates into useEffect what Compose splits across three functions. useEffect(() => {}, []) maps to LaunchedEffect(Unit) for mount-time effects. useEffect(() => {}, [dep]) maps to LaunchedEffect(dep) for dependency-triggered effects. useEffect(() => { return cleanup }) maps to DisposableEffect(key) { onDispose { } } for setup/cleanup patterns. Understanding these correspondences accelerates transition significantly.
Next.js RTL support requires configuring the dir attribute on the HTML element based on locale detection, then using CSS logical properties throughout. Replace margin-left with margin-inline-start, padding-right with padding-inline-end, and text-align: left with text-align: start. Tailwind CSS 3.3+ supports logical property utilities (ms-4, ps-4, text-start) that automatically adapt to text direction. This mirrors Android’s RTL handling but requires explicit CSS architecture decisions.
Internationalization with next-intl follows patterns familiar from Android string resources. JSON message files replace XML, ICU syntax ({count, plural, =0 {None} =1 {One} other {# items}}) replaces <plurals> elements, and the t('key', { variable }) hook replaces getString(R.string.key). TypeScript augmentation provides type safety similar to Android’s generated R class.
Clean architecture in KMP projects structures business logic for maximum reuse
The architecture pattern proven across Cash App, Netflix, McDonald’s, and other production KMP deployments follows Clean Architecture principles: a domain layer with no platform dependencies, a data layer implementing domain interfaces, and presentation consuming domain abstractions.
The domain layer lives entirely in commonMain as pure Kotlin. It contains entity models, repository interfaces, and use case classes. Use cases follow single-responsibility patterns, accepting inputs and producing typed outputs (often sealed classes for success/error states). Flow-based use cases enable reactive data observation. This layer has zero awareness of Ktor, SQLDelight, or any platform specifics.
The data layer implements repository interfaces using Ktor Client for networking and SQLDelight for persistence. Repositories often combine remote and local data sources, fetching from network, caching locally, and falling back to cache on errors. DTOs (Data Transfer Objects) with kotlinx-serialization annotations map to domain models through explicit mapper functions, keeping serialization concerns out of domain code.
The presentation layer in shared modules can use AndroidX ViewModel (now multiplatform via lifecycle-viewmodel-compose) or custom state holders. ViewModels expose StateFlows of UI state consumed by platform views. For iOS Swift interop, SKIE automatically converts Kotlin Flows to Swift AsyncSequence and suspend functions to async functions, eliminating manual wrapper code. KMP-NativeCoroutines provides an alternative approach with explicit @NativeCoroutines annotations.
The expect/actual pattern works best for platform APIs (logging, device info, file storage) rather than business logic. The alternative pattern interfaces in commonMain with platform implementations provided via dependency injection offers better testability. Koin modules separate into commonModule for shared definitions and platformModule for platform-specific bindings.
Module organization typically splits by feature rather than layer at the top level. Each feature module contains domain, data, and presentation packages. iOS requires an umbrella module aggregating all feature modules into a single framework Kotlin/Native limits iOS apps to one Kotlin framework.
Production case studies validate shared logic with native UI as the dominant pattern
Companies processing billions in transactions have validated KMP in production. Cash App has used KMP for 7+ years with 50 mobile engineers and 30 million monthly active users, sharing persistence, networking, and investing features while maintaining native UI. Netflix built their Prodicle app (supporting physical TV/film production) with shared business logic via a “lightweight Hendrix mobile SDK” specifically to support offline functionality that couldn’t move to backend. McDonald’s processes 6.5 million monthly purchases through a KMP-powered app with over 100 million downloads.
The dominant pattern emerging from these deployments combines shared business logic with platform-native UI. Cash App explicitly states that “the vast majority of our code is written natively developer happiness and productivity is still the most important thing for us.” Forbes achieves 80%+ code sharing with this approach. Bitkey (Block’s Bitcoin wallet) reaches 95% shared code. Most teams share networking, data models, business rules, caching, validation, and analytics while keeping UI, animations, and platform integrations native.
Supabase integration through KMP works via the supabase-kt library from the community, providing KMP-compatible modules for auth, PostgREST, functions, storage, and realtime subscriptions. The library requires Ktor 3.0.0+ and uses platform-appropriate engines automatically. Testing Supabase integrations remains challenging teams use Docker Compose with TestContainers mimicking the Supabase stack (PostgreSQL + PostgREST) or mock the internal Ktor engine.
Team structure matters critically for success. The anti-pattern sees Android developers owning the shared module while iOS developers treat it as a black box. Successful implementations involve both Android and iOS engineers in shared code decisions. Cash App’s iOS engineers adapted due to Kotlin’s similarity to Swift, and their server team (also using Kotlin) now contributes to shared repositories.
Build and CI/CD complexity requires attention. iOS builds require macOS runners, adding CI cost. Building multiple architectures bloats build time significantly Touchlab recommends building only the target architecture needed. Pre-building shared frameworks as XCFrameworks reduces iOS developer rebuild time. Typical shared module build times run approximately 5 minutes for iOS framework generation.
Takeaways
Building web applications alongside mobile apps as an Android developer works best with a hybrid architecture that respects platform strengths. KMP excels at sharing business logic networking, validation, data management, domain rules achieving 40-95% code reuse depending on UI strategy. Compose Multiplatform for Web’s canvas rendering makes it unsuitable for public-facing websites requiring SEO and accessibility; Next.js provides the mature web platform experience those requirements demand.
The Supabase-Ktor integration works cleanly when authentication stays client-side. Supabase.js handles auth state, token refresh, and session management automatically. Ktor validates JWTs and makes authorization decisions without ever proxying authentication. RLS enforcement requires either forwarding user tokens or implementing equivalent authorization logic server-side never using service_role keys for user-facing endpoints.
Dependency management in KMP requires accepting platform-aware configuration as fundamental rather than fighting it. Ktor engines, SQLDelight drivers, and datetime formatting all need platform-specific implementations. Version catalogs, convention plugins, and disciplined source set organization make this manageable. The React/Next.js transition feels natural for Compose developers because the declarative paradigm, state management patterns, and component composition models align closely.
Cash App, Netflix, McDonald’s, and dozens of other companies have proven that shared business logic with native UI delivers the best balance of code reuse and platform experience. The KMP ecosystem has matured substantially Compose Multiplatform for iOS reached Stable in May 2025, Google officially recommends KMP for cross-platform business logic, and Jetpack libraries including Room, DataStore, ViewModel, and Paging now support KMP. For Android developers building web applications, this hybrid architecture represents the production-proven path forward.
Happy coding!
David Cruz
davthecoder.com paglipat.com
Loading comments…