The canonical full-stack problem is model drift: a server returns a field the client doesn't expect, or the client sends a payload the server no longer accepts, and the mismatch only surfaces at runtime. REST codegen tools (OpenAPI, gRPC-Web) address this by generating client stubs, but the contract still lives outside your type system—a YAML file, a .proto—and generation is a build step that lags real changes.
Kotlin Multiplatform eliminates the problem at the source. The same Kotlin source file compiles to JVM bytecode (consumed by Ktor on the server) and to JavaScript, iOS, Android, or native targets (consumed by clients). There is no source of truth to synchronize because there is only one source.
Ktor is the natural server counterpart because Ktor Client is itself multiplatform. The same HttpClient API, the same plugin interface, the same serialization configuration—runs anywhere Kotlin runs.
A typical monorepo layout with three Gradle subprojects:
Gradle project layouttext
root/
├── shared/ # KMP module — compiledfor all targets
│ ├── src/commonMain/ # models, API defs, serializers
│ ├── src/jvmMain/ # JVM-specific actuals (if any)
│ └── src/jsMain/ # JS-specific actuals (if any)
├── server/ # Ktor JVM application
│ └── src/main/kotlin/
└── client/ # Compose Multiplatform or KMP client
└── src/commonMain/
server and client both declare a dependency on shared. shared knows nothing about either.
shared/build.gradle.ktskts
kotlin {
jvm()
js(IR) { browser(); nodejs() }
iosX64(); iosArm64(); iosSimulatorArm64()
androidTarget()
sourceSets {
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("io.ktor:ktor-resources:3.0.3") // typed routes
}
}
}
}
server/build.gradle.ktskts
dependencies {
implementation(project(":shared"))
implementation("io.ktor:ktor-server-core-jvm:3.0.3")
implementation("io.ktor:ktor-server-netty-jvm:3.0.3")
implementation("io.ktor:ktor-server-resources:3.0.3")
implementation("io.ktor:ktor-server-content-negotiation-jvm:3.0.3")
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:3.0.3")
implementation("io.ktor:ktor-server-status-pages-jvm:3.0.3")
implementation("io.ktor:ktor-server-auth-jvm:3.0.3")
}
server/src/main/kotlin/Application.ktkotlin
fun main() = embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true)
fun Application.module() {
install(Resources)
install(ContentNegotiation) { json() }
install(StatusPages) {
exception<Throwable> { call, cause ->
call.respond(
HttpStatusCode.InternalServerError,
ApiError("INTERNAL", cause.message ?: "Unknown error")
)
}
}
configureRouting()
}
The server uses the same Posts and UserPosts resource classes defined in shared. The compiler enforces that path parameters match the class structure—a missing id field is a compile error, not a 404.
server/src/main/kotlin/routes/PostRoutes.ktkotlin
fun Application.configureRouting() {
routing {
get<Posts> {
val posts = postRepository.findAll()
call.respond(posts)
}
get<Posts.ById> { resource ->
val post = postRepository.findById(resource.id)
?: return@get call.respond(
HttpStatusCode.NotFound,
ApiError("NOT_FOUND", "Post ${resource.id} not found")
)
call.respond(post)
}
post<Posts> {
val req = call.receive<CreatePostRequest>()
val created = postRepository.create(req, currentUser.id)
call.respond(HttpStatusCode.Created, created)
}
get<UserPosts> { resource ->
val posts = postRepository.findByUser(resource.userId, resource.page)
call.respond(posts)
}
}
}
call.receive<T>() and call.respond(T) use the registered ContentNegotiation converter. Because T is your shared @Serializable class, the JSON schema is never written by hand.
Ktor Client is configured identically in commonMain. Engine selection is the only target-specific part—handled via expect/actual or Gradle source sets.
client/src/commonMain/kotlin/network/ApiClient.ktkotlin
val httpClient = HttpClient {
install(Resources)
install(ContentNegotiation) { json() }
install(Auth) {
bearer {
loadTokens { BearerTokens(tokenStorage.accessToken(), tokenStorage.refreshToken()) }
refreshTokens { /* call /auth/refresh */ }
}
}
defaultRequest { url("https://api.example.com") }
}
// Usage — mirror of server route definitions
suspend fun getPost(id: Long): Post =
httpClient.get(Posts.ById(id = id)).body()
suspend fun createPost(req: CreatePostRequest): Post =
httpClient.post(Posts()) {
contentType(ContentType.Application.Json)
setBody(req)
}.body()
.body<T>() will throw ResponseException on non-2xx responses. Wrap calls in a runCatching or a custom result type in your repository layer—don't let raw Ktor exceptions propagate into UI code.
| Target | Engine | Dependency |
|---|---|---|
| JVM (server / Android) | CIO or OkHttp | ktor-client-cio-jvm |
| JS (browser) | Js (fetch API) | ktor-client-js |
| JS (Node.js) | Js (node-fetch) | ktor-client-js |
| iOS / macOS | Darwin (NSURLSession) | ktor-client-darwin |
| Linux / Windows | CIO | ktor-client-cio |
Wire the engine in a platform-specific source set or via an expect/actual factory so commonMain stays engine-agnostic.
Ktor's JWT plugin and client Bearer plugin share the same token model. Define token types in shared:
shared/src/commonMain/kotlin/api/Auth.ktkotlin
@Serializable
data class LoginRequest(val email: String, val password: String)
@Serializable
data class TokenPair(val accessToken: String, val refreshToken: String)
server — JWT plugin configurationkotlin
install(Authentication) {
jwt("auth-jwt") {
realm = "api"
verifier(
JWT.require(Algorithm.HMAC256(secret))
.withAudience(audience)
.withIssuer(issuer)
.build()
)
validate { credential ->
if (credential.payload.audience.contains(audience))
JWTPrincipal(credential.payload) else null
}
challenge { _, _ ->
call.respond(HttpStatusCode.Unauthorized, ApiError("UNAUTHORIZED", "Token invalid or expired"))
}
}
}
// Protect a route group
authenticate("auth-jwt") {
post<Posts> { /* ... */ }
delete<Posts.ById> { /* ... */ }
}
kotlinx.serialization is the only serialization library that works across all KMP targets. Configure a shared Json instance in commonMain so behaviour is identical everywhere:
shared/src/commonMain/kotlin/api/Json.ktkotlin
val AppJson = Json {
ignoreUnknownKeys = true // forward compatibility
isLenient = false
encodeDefaults = true
explicitNulls = false // omit null fields from output
coerceInputValues = true // enum fallback to default
}
Pass this instance to both Ktor Server's ContentNegotiation and Ktor Client's ContentNegotiation:
both server and clientkotlin
install(ContentNegotiation) { json(AppJson) }
Use @Serializable sealed classes for discriminated unions. Register a serializers module in AppJson if you need custom discriminator keys:
shared/commonMainkotlin
@Serializable
@JsonClassDiscriminator("type")
sealed class Notification {
@Serializable @SerialName("comment")
data class Comment(val postId: Long, val text: String) : Notification()
@Serializable @SerialName("follow")
data class Follow(val fromUserId: Long) : Notification()
}
Ktor provides testApplication—a test harness that starts an in-memory server. Because the test client uses the same Ktor Client API, you can share test utilities across unit and integration tests.
server/src/test/kotlin/routes/PostRoutesTest.ktkotlin
class PostRoutesTest {
@Test
fun `GET post returns 404 when missing`() = testApplication {
application { module() }
val client = createClient {
install(Resources)
install(ContentNegotiation) { json(AppJson) }
}
val response = client.get(Posts.ById(id = 99999L))
assertEquals(HttpStatusCode.NotFound, response.status)
val error = response.body<ApiError>()
assertEquals("NOT_FOUND", error.code)
}
}
The test references Posts.ById from shared—the same type the route handler pattern-matches on. If the route definition changes, the test breaks at compile time.
Build a fat JAR with the Shadow plugin or an OCI image with Jib. No changes to how Ktor is deployed—the only addition is a dependency on your shared subproject.
terminalshell
./gradlew :server:shadowJar
java -jar server/build/libs/server-all.jar
terminalshell
./gradlew :client:jsBrowserProductionWebpack
terminalshell
./gradlew :shared:linkReleaseFrameworkIosArm64
# outputs shared/build/bin/iosArm64/releaseFramework/shared.framework
| Feature | JVM Server | JVM Client | JS | iOS | Android |
|---|---|---|---|---|---|
| kotlinx.serialization | ✓ | ✓ | ✓ | ✓ | ✓ |
| Ktor Resources (typed routes) | ✓ | ✓ | ✓ | ✓ | ✓ |
| Ktor Client WebSockets | ✓ | ✓ | ✓ | ✓ | ✓ |
| Ktor Server WebSockets | ✓ | — | — | — | — |
| kotlinx.coroutines | ✓ | ✓ | ✓ | ✓ | ✓ |
| Ktor Client streaming | ✓ | ✓ | ✓ | ✓ | ✓ |
| Exposed ORM | ✓ | — | ✗ | ✗ | — |
server/jvmMain. The shared module must compile on all targets, so any JVM-only library that leaks into commonMain will break the build. Repositories in shared should be interfaces; implementations live in server.