Ktor + Kotlin Multiplatform:
Full-Stack Type-Safe Development

Why this stack

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.

shared data class User ApiRoutes serializers
server Ktor JVM exposed / mongo
client Ktor-client JS Ktor-client iOS Ktor-client Android

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.

Project structure

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 } } } }

Shared models

Every request/response type lives in shared/commonMain. Annotate with @Serializable from kotlinx.serialization—this is the only serialization library with multiplatform support that Ktor uses natively.

      
shared/src/commonMain/kotlin/models/Post.ktkotlin
import kotlinx.serialization.Serializable @Serializable data class Post( val id: Long, val title: String, val body: String, val authorId: Long, val publishedAt: Long? // epoch ms; null = draft ) @Serializable data class CreatePostRequest( val title: String, val body: String ) @Serializable data class ApiError( val code: String, val message: String )

Typed route resources

Ktor's Resources plugin lets you declare route shapes as annotated classes. These also live in shared, giving both server and client the same URL structure.

      
shared/src/commonMain/kotlin/api/Routes.ktkotlin
import io.ktor.resources.Resource import kotlinx.serialization.Serializable @Serializable @Resource("/posts") class Posts { @Serializable @Resource("{id}") data class ById(val parent: Posts = Posts(), val id: Long) } @Serializable @Resource("/users/{userId}/posts") data class UserPosts(val userId: Long, val page: Int = 1)
Note Query parameters declared as constructor properties on @Resource classes are automatically serialized into the URL and deserialized on receipt—no manual string interpolation on either side.

Server setup

      
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() }

Routing & resources

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.

Client setup

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()
Important .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.

Engine per target

Target Engine Dependency
JVM (server / Android)CIO or OkHttpktor-client-cio-jvm
JS (browser)Js (fetch API)ktor-client-js
JS (Node.js)Js (node-fetch)ktor-client-js
iOS / macOSDarwin (NSURLSession)ktor-client-darwin
Linux / WindowsCIOktor-client-cio

Wire the engine in a platform-specific source set or via an expect/actual factory so commonMain stays engine-agnostic.

Authentication

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> { /* ... */ } }

Serialization

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) }

Sealed classes & polymorphism

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() }

Testing

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 targets & deployment

Server (JVM)

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

JS client

      
terminalshell
./gradlew :client:jsBrowserProductionWebpack

iOS framework

      
terminalshell
./gradlew :shared:linkReleaseFrameworkIosArm64 # outputs shared/build/bin/iosArm64/releaseFramework/shared.framework

Feature support matrix

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
Tip Keep database access (Exposed, R2DBC, Mongo) in 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.