You have built a complete Ktor backend with authentication, database, WebSockets, Docker, and CI/CD. Now it is time to connect everything — a Kotlin backend and a Kotlin client sharing the same data models.

In this final tutorial, you will create shared data models, build a Ktor Client that consumes your API, and see how a KMP mobile app connects to your backend.

The Full-Stack Kotlin Vision

Most full-stack projects look like this:

Backend (Python/Node.js) → JSON → Frontend (TypeScript/JavaScript)

You define models on both sides. You keep them in sync manually. When the API changes, the client breaks.

With Kotlin, you can share models:

Backend (Ktor Server) → Shared Kotlin Module → Client (Ktor Client / KMP)

Same @Serializable data classes. Same validation. One language. When you change a model, both sides update together.

Project Structure

In a real full-stack Kotlin project, you have three Gradle modules:

project/
├── shared/            ← Shared DTOs (used by server + client)
│   └── src/commonMain/kotlin/models/
├── server/            ← Ktor Server (this series)
│   └── src/main/kotlin/
└── client/            ← KMP Client (Android, iOS, Desktop)
    └── src/commonMain/kotlin/

For this tutorial, we keep everything in one module to keep it simple. The shared models live in com.kemalcodes.shared. In production, move them to a separate Gradle module.

Shared Data Models

These @Serializable classes are used by both the server and the client:

package com.kemalcodes.shared

import kotlinx.serialization.Serializable

// --- Auth DTOs ---

@Serializable
data class SharedLoginRequest(
    val email: String,
    val password: String
)

@Serializable
data class SharedRegisterRequest(
    val name: String,
    val email: String,
    val password: String
)

@Serializable
data class SharedTokenResponse(
    val accessToken: String,
    val refreshToken: String,
    val expiresIn: Long = 3600
)

@Serializable
data class SharedRefreshRequest(
    val refreshToken: String
)

// --- Note DTOs ---

@Serializable
data class SharedNoteResponse(
    val id: Int,
    val title: String,
    val content: String,
    val userId: Int? = null,
    val authorName: String? = null,
    val tags: List<String> = emptyList(),
    val createdAt: String? = null
)

@Serializable
data class SharedCreateNoteRequest(
    val title: String,
    val content: String,
    val userId: Int? = null,
    val tags: List<String> = emptyList()
)

// --- User DTOs ---

@Serializable
data class SharedUserProfile(
    val id: Int,
    val name: String,
    val email: String,
    val role: String
)

@Serializable
data class SharedErrorResponse(
    val message: String,
    val code: Int
)

Why “Shared” Prefix?

The server already has LoginRequest, NoteResponse, and other models for its internal use. The “Shared” prefix distinguishes the shared DTOs from server-internal models.

In a real multi-module project, you would not need the prefix. The shared module has its own package, and imports resolve unambiguously.

Why Separate from Server Models?

Server models might include database-specific fields, internal IDs, or audit columns that the client should never see. Shared models define the API contract — what the client sends and receives.

Server Model (internal):
  id, title, content, userId, createdAt, updatedAt, deletedAt, version

Shared Model (API contract):
  id, title, content, userId, authorName, tags, createdAt

The Ktor Client

Ktor Client is the HTTP client counterpart to Ktor Server. It uses the same coroutines, serialization, and plugin system.

package com.kemalcodes.client

import com.kemalcodes.shared.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json

class KtorApiClient(
    private val baseUrl: String = "http://localhost:8080",
    private val httpClient: HttpClient = createDefaultClient()
) {
    private var accessToken: String? = null

    // --- Auth ---

    suspend fun register(
        name: String,
        email: String,
        password: String
    ): SharedTokenResponse {
        val response = httpClient.post("$baseUrl/api/auth/register") {
            contentType(ContentType.Application.Json)
            setBody(SharedRegisterRequest(name, email, password))
        }.body<SharedTokenResponse>()

        accessToken = response.accessToken
        return response
    }

    suspend fun login(email: String, password: String): SharedTokenResponse {
        val response = httpClient.post("$baseUrl/api/auth/login") {
            contentType(ContentType.Application.Json)
            setBody(SharedLoginRequest(email, password))
        }.body<SharedTokenResponse>()

        accessToken = response.accessToken
        return response
    }

    suspend fun getProfile(): SharedUserProfile {
        return httpClient.get("$baseUrl/api/auth/me") {
            bearerAuth(accessToken
                ?: throw IllegalStateException("Not logged in"))
        }.body()
    }

    suspend fun refreshToken(refreshToken: String): SharedTokenResponse {
        val response = httpClient.post("$baseUrl/api/auth/refresh") {
            contentType(ContentType.Application.Json)
            setBody(SharedRefreshRequest(refreshToken))
        }.body<SharedTokenResponse>()

        accessToken = response.accessToken
        return response
    }

    // --- Notes ---

    suspend fun getNotes(
        page: Int = 1,
        size: Int = 10
    ): List<SharedNoteResponse> {
        return httpClient.get("$baseUrl/api/notes") {
            parameter("page", page)
            parameter("size", size)
        }.body()
    }

    suspend fun getNote(id: Int): SharedNoteResponse {
        return httpClient.get("$baseUrl/api/notes/$id").body()
    }

    suspend fun createNote(
        title: String,
        content: String,
        tags: List<String> = emptyList()
    ): SharedNoteResponse {
        return httpClient.post("$baseUrl/api/notes") {
            contentType(ContentType.Application.Json)
            setBody(SharedCreateNoteRequest(title, content, tags = tags))
        }.body()
    }

    fun close() {
        httpClient.close()
    }

    companion object {
        fun createDefaultClient(): HttpClient {
            return HttpClient(io.ktor.client.engine.java.Java) {
                install(ContentNegotiation) {
                    json(Json {
                        ignoreUnknownKeys = true
                        prettyPrint = true
                    })
                }
            }
        }
    }
}

How It Works

The client mirrors the server API. Each server endpoint has a corresponding client method:

Server EndpointClient Method
POST /api/auth/registerregister()
POST /api/auth/loginlogin()
GET /api/auth/megetProfile()
POST /api/auth/refreshrefreshToken()
GET /api/notesgetNotes()
GET /api/notes/{id}getNote()
POST /api/notescreateNote()

Token Management

private var accessToken: String? = null

suspend fun login(email: String, password: String): SharedTokenResponse {
    val response = httpClient.post("$baseUrl/api/auth/login") {
        // ...
    }.body<SharedTokenResponse>()

    accessToken = response.accessToken  // Store token
    return response
}

suspend fun getProfile(): SharedUserProfile {
    return httpClient.get("$baseUrl/api/auth/me") {
        bearerAuth(accessToken ?: throw IllegalStateException("Not logged in"))
    }.body()
}

After login, the client stores the access token. Subsequent requests include it as a Bearer token. In a real app, you would store the token securely (Keychain on iOS, EncryptedSharedPreferences on Android).

Platform-Specific Engines

The example uses Java engine because we are on JVM. In a KMP project, each platform uses its own engine:

// Android
HttpClient(OkHttp) { /* config */ }

// iOS
HttpClient(Darwin) { /* config */ }

// Desktop/JVM
HttpClient(Java) { /* config */ }

The API is identical. Only the engine changes. This is covered in KMP Tutorial #6: Networking with Ktor Client.

Using the Client

suspend fun main() {
    val client = KtorApiClient("http://localhost:8080")

    // Register
    val tokens = client.register("Alex", "alex@example.com", "password123")
    println("Access token: ${tokens.accessToken}")

    // Get profile
    val profile = client.getProfile()
    println("Welcome, ${profile.name}!")

    // Create a note
    val note = client.createNote(
        title = "My First Note",
        content = "Hello from the client!",
        tags = listOf("kotlin", "ktor")
    )
    println("Created note: ${note.title}")

    // List notes
    val notes = client.getNotes()
    println("Total notes: ${notes.size}")

    client.close()
}

Moving to a Shared Gradle Module

In production, extract shared models into a separate Gradle module:

// settings.gradle.kts
include(":shared", ":server", ":client")
// shared/build.gradle.kts
plugins {
    kotlin("multiplatform")
    kotlin("plugin.serialization")
}

kotlin {
    jvm()  // Used by server
    iosArm64()
    iosSimulatorArm64()

    sourceSets {
        commonMain {
            dependencies {
                implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
            }
        }
    }
}
// server/build.gradle.kts
dependencies {
    implementation(project(":shared"))
    // ... ktor server deps
}

// client/build.gradle.kts (KMP)
kotlin {
    sourceSets {
        commonMain {
            dependencies {
                implementation(project(":shared"))
                // ... ktor client deps
            }
        }
    }
}

Now both server and client depend on :shared. When you add a field to SharedNoteResponse, both sides see the change immediately. If you forget to update the server, the compiler catches it.

API Contract Testing

With shared models, type safety prevents most mismatches. But you should still test the full API contract:

@Test
fun `server response matches shared model`() = testApplication {
    application { module() }
    val client = jsonClient()

    // Create with shared model
    val response = client.post("/api/notes") {
        contentType(ContentType.Application.Json)
        setBody(SharedCreateNoteRequest("Test", "Content"))
    }

    // Deserialize with shared model
    val note = response.body<SharedNoteResponse>()
    assertEquals("Test", note.title)
    assertNotNull(note.id)
}

This test verifies that the server produces responses that match the shared model. If the server adds a required field that the shared model does not have, the test fails.

API Versioning

When you change the API, old clients break. Version your API from the start:

route("/api/v1") {
    noteRoutes(noteRepository)
    authRoutes(userRepository, refreshTokenRepository)
}

When you make breaking changes, create a new version:

route("/api/v1") { /* old endpoints */ }
route("/api/v2") { /* new endpoints */ }

Keep the old version running until all clients update. Shared models help here — you can have v1 and v2 model variants in the shared module.

Offline-First Pattern

Mobile apps need to work without internet. The pattern:

  1. Store data locally (SQLDelight — covered in KMP Tutorial #7)
  2. Show local data immediately
  3. Sync with the server in the background
  4. Handle conflicts when both local and remote data changed
Client reads: Local DB → Display
Client writes: Local DB → Sync Queue → Server
Server responds: Server → Local DB → Display updated

This is a complex topic. The key insight: your Ktor API client becomes one part of a larger data layer that includes local storage and sync logic.

Architecture Diagram

Here is the complete full-stack architecture:

┌─────────────────────────────────────────────┐
│                 KMP Client                   │
│  ┌──────────┐  ┌──────────┐  ┌───────────┐ │
│  │ UI Layer │──│ViewModel │──│ Repository│ │
│  └──────────┘  └──────────┘  └───────────┘ │
│                                 │    │       │
│                         ┌──────┘    └────┐  │
│                   ┌─────┴───┐    ┌───────┴┐ │
│                   │ SQLDelight│    │ Ktor   │ │
│                   │ (local) │    │ Client │ │
│                   └─────────┘    └────────┘ │
└─────────────────────────────────┬───────────┘
                                  │ HTTPS
┌─────────────────────────────────┴───────────┐
│                 Ktor Server                  │
│  ┌────────┐  ┌──────────┐  ┌────────────┐  │
│  │ Routes │──│ Services │──│ Repositories│  │
│  └────────┘  └──────────┘  └────────────┘  │
│                                  │           │
│                          ┌───────┴────────┐  │
│                          │   PostgreSQL   │  │
│                          └────────────────┘  │
└──────────────────────────────────────────────┘

       Shared Module: @Serializable DTOs

What We Built in This Series

Over 22 tutorials, you built a production-ready Kotlin backend:

TutorialWhat You Learned
#1-2What is Ktor, Ktor vs Spring Boot
#3-5Project setup, routing, serialization
#6-9Database with Exposed, CRUD, relationships, file uploads
#10Database migrations with Flyway
#11-14JWT auth, registration/login, OAuth, security
#15-17WebSockets, OpenAPI, HTMX
#18-19Dependency injection, testing
#20-22Docker, CI/CD, full-stack integration

What to Learn Next

Your Ktor foundation is solid. Here are paths to explore:

Scaling:

  • Kubernetes for container orchestration
  • Horizontal scaling with multiple Ktor instances
  • Redis for caching and session storage

Advanced APIs:

  • GraphQL with Ktor (KGraphQL or graphql-kotlin)
  • gRPC for high-performance service-to-service communication
  • Server-Sent Events for real-time notifications

Architecture:

  • Microservices with Ktor
  • Event-driven architecture with Kafka
  • CQRS and Event Sourcing patterns

KMP Integration:

  • Build the mobile client from KMP Tutorial series
  • Share business logic between server and client
  • Implement offline-first sync

Error Handling on the Client

Network requests fail. The client should handle errors gracefully:

suspend fun getNotesOrEmpty(): List<SharedNoteResponse> {
    return try {
        getNotes()
    } catch (e: Exception) {
        emptyList()  // Return empty list on error
    }
}

For better error handling, use Ktor’s HttpResponse status code:

suspend fun login(email: String, password: String): Result<SharedTokenResponse> {
    val response = httpClient.post("$baseUrl/api/auth/login") {
        contentType(ContentType.Application.Json)
        setBody(SharedLoginRequest(email, password))
    }
    return when (response.status) {
        HttpStatusCode.OK -> Result.success(response.body())
        HttpStatusCode.Unauthorized -> Result.failure(Exception("Invalid credentials"))
        else -> Result.failure(Exception("Server error: ${response.status}"))
    }
}

The Result type lets the caller handle success and failure without try-catch. This pattern integrates well with ViewModel architecture in KMP apps.

Common Mistakes

  1. Duplicating data classes — Share models through a Gradle module instead of copy-pasting classes between server and client.

  2. Not versioning the API — Breaking changes without versioning break all existing clients.

  3. Breaking client compatibility — Adding a required field to a response breaks old clients. Make new fields optional with default values.

  4. Exposing server internals — Shared models should contain only what the client needs. Never expose database IDs, internal timestamps, or audit fields.

  5. Ignoring the offline case — Mobile users lose connectivity. Design your API and client to handle network failures gracefully.

Source Code

You can find the source code for this tutorial on GitHub:

github.com/kemalcodes/ktor-tutorial — Branch: tutorial-22-fullstack