Ktor 3.2.1 Help

Build a full-stack application with Kotlin Multiplatform

In this article, you will learn how to develop a full-stack application in Kotlin that runs on Android, iOS, and desktop platforms, all while leveraging Ktor for seamless data handling.

By the end of this tutorial, you’ll know how to do the following:

  • Create full-stack applications using Kotlin Multiplatform.

  • Understand the project generated with IntelliJ IDEA.

  • Create Compose Multiplatform clients that call Ktor services.

  • Reuse shared types across different layers of your design.

  • Correctly include and configure multiplatform libraries.

In previous tutorials, we used the Task Manager example to handle requests, create RESTful APIs, and integrate a database with Exposed. Client applications were kept as minimal as possible, so you could focus on learning the fundamentals of Ktor.

You will create a client that will target the Android, iOS, and desktop platforms, using a Ktor service to acquire the data to be displayed. Wherever possible you will share data types between the client and the server, speeding up development and reducing the potential for errors.

Prerequisites

As in previous articles, you will use IntelliJ IDEA as the IDE. To install and configure your environment, see the Kotlin Multiplatform quickstart guide.

If this is the first time you use Compose Multiplatform, we recommend that you complete the Get started with Compose Multiplatform tutorial before starting this one. To reduce the complexity of the task, you can focus on a single client platform. For example, if you have never used iOS, it might be wise to focus on desktop or Android development.

Create a new project

Instead of the Ktor project generator, use the Kotlin Multiplatform project wizard in IntelliJ IDEA. It will create a basic multiplatform project which you can expand with clients and services. The clients can either use a native UI library, such as SwiftUI, but in this tutorial you will create a shared UI for all platforms by using Compose Multiplatform.

  1. Launch IntelliJ IDEA.

  2. In IntelliJ IDEA, select File | New | Project.

  3. In the panel on the left, select Kotlin Multiplatform.

  4. Specify the following fields in the New Project window:

    • Name: full-stack-task-manager

    • Group: com.example.ktor

  5. Select Android, Desktop, and Server as the targeted platforms.

  6. If you're using a Mac, select iOS as well. Make sure that the Share UI option is selected.

    Kotlin Multiplatform wizard settings

  7. Click the Create button and wait for the IDE to generate and import the project.

Run the service

  1. In the Project view, navigate to server/src/main/kotlin/com/example/ktor/full_stack_task_manager and open the Application.kt file.

  2. Click on the Run button (IntelliJ IDEA run icon) next to the main() function to start the application.

    A new tab in the Run tool window will open with the log ending with the message "Responding at http://0.0.0.0:8080".

  3. Navigate to http://0.0.0.0:8080/ to open the application. You should see a message from Ktor displayed in the browser.

    A Ktor server browser response

Examine the project

The server folder is one of three Kotlin modules in the project. The other two are shared and composeApp.

The structure of the server module is very similar to that produced by the Ktor Project Generator. You have a dedicated build file to declare plugins and dependencies, and a source set containing the code to build and launch a Ktor service:

Contents of the server folder in a Kotlin Multiplatform project

If you look at the routing instructions in the Application.kt file, you will see a call of the greet() function:

fun Application.module() { routing { get("/") { call.respondText("Ktor: ${Greeting().greet()}") } } }

This creates an instance of the Greeting type and invokes its greet() method. The Greeting class is defined in the shared module:

Greeting.kt and Platform.kt opened in IntelliJ IDEA

The shared module contains code that will be used across different target platforms.

The commonMain source in the shared module set holds types that will be used on all platforms. As you can see this is where the Greeting type is defined. This is also where you will put the common code to be shared between the server and all of the different client platforms.

The shared module also contains a source set for each platform where you wish to provide a client. This is because types declared within commonMain may require functionality that varies by target platform. In the case of the Greeting type, you want to get the name of the current platform using platform-specific API. This is achieved through expected and actual declarations.

In the commonMain source set of the shared module you declare a getPlatform() function with the expect keyword:

package com.example.ktor.full_stack_task_manager interface Platform { val name: String } expect fun getPlatform(): Platform

Then each target platform must provide an actual declaration of the getPlatform() function, as shown below:

package com.example.ktor.full_stack_task_manager import platform.UIKit.UIDevice class IOSPlatform: Platform { override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion } actual fun getPlatform(): Platform = IOSPlatform()
package com.example.ktor.full_stack_task_manager import android.os.Build class AndroidPlatform : Platform { override val name: String = "Android ${Build.VERSION.SDK_INT}" } actual fun getPlatform(): Platform = AndroidPlatform()
package com.example.ktor.full_stack_task_manager class JVMPlatform: Platform { override val name: String = "Java ${System.getProperty("java.version")}" } actual fun getPlatform(): Platform = JVMPlatform()
package com.example.ktor.full_stack_task_manager class WasmPlatform : Platform { override val name: String = "Web with Kotlin/Wasm" } actual fun getPlatform(): Platform = WasmPlatform()

There is one additional module in the project, the composeApp module. It contains the code for the Android, iOS, desktop, and web client apps. These apps are not linked to the Ktor service at the moment, but they do use the shared Greeting class.

Run a client application

You can run a client application by executing the run configuration for the target. To run the application on an iOS simulator, follow the steps below:

  1. In IntelliJ IDEA, select the iosApp run configuration and a simulated device.

    Run & Debug window
  2. Click the Run button (IntelliJ IDEA run icon) to run the configuration.

  3. When you run the iOS app, it is built with Xcode under the hood and launched in the iOS Simulator. The app displays a button that toggles an image on click.

    Running the app in the iOS Simulator

    When the button is pressed for the first time, the details of the current platform are added to its text. The code to achieve this is found in composeApp/src/commonMain/kotlin/com/example/ktor/full_stack_task_manager/App.kt:

    @Composable fun App() { MaterialTheme { var greetingText by remember { mutableStateOf("Hello World!") } var showImage by remember { mutableStateOf(false) } Column( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Button(onClick = { greetingText = "Compose: ${Greeting().greet()}" showImage = !showImage }) { Text(greetingText) } AnimatedVisibility(showImage) { Image( painterResource(Res.drawable.compose_multiplatform), null ) } } } }

    This is a composable function, which you will modify later in this article. For the moment all that is important is that it displays a UI and makes use of the shared Greeting type, which in turn uses the platform-specific classes implementing the common Platform interface.

Now that you understand the structure of the generated project, you can incrementally add the task manager functionality.

Add the model types

First, add the model types and make sure that they are accessible for both the client and the server.

  1. Navigate to shared/src/commonMain/kotlin/com/example/ktor/full_stack_task_manager and create a new package called model.

  2. Inside the new package, create a new file called Task.kt.

  3. Add an enum to represent priorities and a class to represent tasks. The Task class is annotated with the Serializable type from the kotlinx.serialization library:

    package com.example.ktor.full_stack_task_manager.model import kotlinx.serialization.Serializable enum class Priority { Low, Medium, High, Vital } @Serializable data class Task( val name: String, val description: String, val priority: Priority )

    You will notice that neither the import nor the annotation compiles. This is because the project doesn't have a dependency on the kotlinx.serialization library yet.

  4. Navigate to shared/build.gradle.kts and add the serialization plugin:

    plugins { //... kotlin("plugin.serialization") version "2.1.21" }
  5. In the same file, add a new dependency to the commonMain source set:

    sourceSets { commonMain.dependencies { // put your Multiplatform dependencies here implementation(libs.kotlinx.serialization.json) } //... }
  6. Navigate to gradle/libs.versions.toml and define the following:

    [versions] kotlinxSerializationJson = "1.8.1" [libraries] kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
  7. In IntelliJ IDEA, select Build | Sync Project with Gradle Files to apply the update. Once Gradle import has finished, you should find that your Task.kt file compiles successfully.

Note that the code would have compiled without including the serialization plugin, however, the types required to serialize Task objects across the network would not have been produced. This would lead to runtime errors when attempting to invoke the service.

Placing the serialization plugin in another module (such as server or composeApp) would not have caused an error at build time. But again, the additional types required for serialization would not have been generated, leading to runtime errors.

Create the server

The next stage is to create the server implementation for our task manager.

  1. Navigate to the server/src/main/kotlin/com/example/ktor/full_stack_task_manager folder and create a subpackage called model.

  2. Inside this package, create a new TaskRepository.kt file and add the following interface for our repository:

    package com.example.ktor.full_stack_task_manager.model interface TaskRepository { fun allTasks(): List<Task> fun tasksByPriority(priority: Priority): List<Task> fun taskByName(name: String): Task? fun addOrUpdateTask(task: Task) fun removeTask(name: String): Boolean }
  3. In the same package, create a new file called InMemoryTaskRepository.kt containing the following class:

    package com.example.ktor.full_stack_task_manager.model class InMemoryTaskRepository : TaskRepository { private var tasks = listOf( Task("Cleaning", "Clean the house", Priority.Low), Task("Gardening", "Mow the lawn", Priority.Medium), Task("Shopping", "Buy the groceries", Priority.High), Task("Painting", "Paint the fence", Priority.Low), Task("Cooking", "Cook the dinner", Priority.Medium), Task("Relaxing", "Take a walk", Priority.High), Task("Exercising", "Go to the gym", Priority.Low), Task("Learning", "Read a book", Priority.Medium), Task("Snoozing", "Go for a nap", Priority.High), Task("Socializing", "Go to a party", Priority.High) ) override fun allTasks(): List<Task> = tasks override fun tasksByPriority(priority: Priority) = tasks.filter { it.priority == priority } override fun taskByName(name: String) = tasks.find { it.name.equals(name, ignoreCase = true) } override fun addOrUpdateTask(task: Task) { var notFound = true tasks = tasks.map { if (it.name == task.name) { notFound = false task } else { it } } if (notFound) { tasks = tasks.plus(task) } } override fun removeTask(name: String): Boolean { val oldTasks = tasks tasks = tasks.filterNot { it.name == name } return oldTasks.size > tasks.size } }
  4. Navigate to server/src/main/kotlin/.../Application.kt and replace the existing code with the implementation below:

    package com.example.ktor.full_stack_task_manager import com.example.ktor.full_stack_task_manager.model.InMemoryTaskRepository import com.example.ktor.full_stack_task_manager.model.Priority import com.example.ktor.full_stack_task_manager.model.Task import io.ktor.http.* import io.ktor.serialization.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.plugins.cors.routing.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* fun main() { embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module) .start(wait = true) } fun Application.module() { install(ContentNegotiation) { json() } install(CORS) { allowHeader(HttpHeaders.ContentType) allowMethod(HttpMethod.Delete) // For ease of demonstration we allow any connections. // Don't do this in production. anyHost() } val repository = InMemoryTaskRepository() routing { route("/tasks") { get { val tasks = repository.allTasks() call.respond(tasks) } get("/byName/{taskName}") { val name = call.parameters["taskName"] if (name == null) { call.respond(HttpStatusCode.BadRequest) return@get } val task = repository.taskByName(name) if (task == null) { call.respond(HttpStatusCode.NotFound) return@get } call.respond(task) } get("/byPriority/{priority}") { val priorityAsText = call.parameters["priority"] if (priorityAsText == null) { call.respond(HttpStatusCode.BadRequest) return@get } try { val priority = Priority.valueOf(priorityAsText) val tasks = repository.tasksByPriority(priority) if (tasks.isEmpty()) { call.respond(HttpStatusCode.NotFound) return@get } call.respond(tasks) } catch (ex: IllegalArgumentException) { call.respond(HttpStatusCode.BadRequest) } } post { try { val task = call.receive<Task>() repository.addOrUpdateTask(task) call.respond(HttpStatusCode.NoContent) } catch (ex: IllegalStateException) { call.respond(HttpStatusCode.BadRequest) } catch (ex: JsonConvertException) { call.respond(HttpStatusCode.BadRequest) } } delete("/{taskName}") { val name = call.parameters["taskName"] if (name == null) { call.respond(HttpStatusCode.BadRequest) return@delete } if (repository.removeTask(name)) { call.respond(HttpStatusCode.NoContent) } else { call.respond(HttpStatusCode.NotFound) } } } } }

    This implementation is very similar to that in previous tutorials with the exception that now you have placed all the routing code within the Application.module() function for simplicity.

    Once you have entered this code and added the imports, you will find multiple compiler errors because the code uses multiple Ktor plugins that need to be included as dependencies, including the CORS plugin for interacting with the web client.

  5. Open the gradle/libs.versions.toml file and define the following libraries:

    [libraries] ktor-serialization-kotlinx-json-jvm = { module = "io.ktor:ktor-serialization-kotlinx-json-jvm", version.ref = "ktor" } ktor-server-content-negotiation-jvm = { module = "io.ktor:ktor-server-content-negotiation-jvm", version.ref = "ktor" } ktor-server-cors-jvm = { module = "io.ktor:ktor-server-cors-jvm", version.ref = "ktor" }
  6. Open the server module build file (server/build.gradle.kts) and add the following dependencies:

    dependencies { //... implementation(libs.ktor.serialization.kotlinx.json.jvm) implementation(libs.ktor.server.content.negotiation.jvm) implementation(libs.ktor.server.cors.jvm) }
  7. Once again, Build | Sync Project with Gradle Files in the main menu. After the import finishes, you should find that the imports for the ContentNegotiation type and json() function work correctly.

  8. Rerun the server. You should find that the routes are reachable from the browser.

  9. Navigate to http://0.0.0.0:8080/tasks and http://0.0.0.0:8080/tasks/byPriority/Medium to see server responses with tasks in the JSON format.

    Server response in browser

Create the client

For your clients to be able to access the server, you need to include the Ktor Client. There are three types of dependencies involved in this:

  • The core functionality for the Ktor Client.

  • Platform-specific engines to handle networking.

  • Support for content negotiation and serialization.

  1. In the gradle/libs.versions.toml file, add the following libraries:

    [libraries] ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-client-wasm = { module = "io.ktor:ktor-client-js-wasm-js", version.ref = "ktor"} ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
  2. Navigate to composeApp/build.gradle.kts and add the following dependencies:

    kotlin { //... sourceSets { val desktopMain by getting androidMain.dependencies { //... implementation(libs.ktor.client.android) } commonMain.dependencies { //... implementation(libs.ktor.client.core) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) } desktopMain.dependencies { //... implementation(libs.ktor.client.cio) } iosMain.dependencies { implementation(libs.ktor.client.darwin) } wasmJsMain.dependencies { implementation(libs.ktor.client.wasm) } } }

    Once this is done you can add a TaskApi type for your clients to act as a thin wrapper around the Ktor Client.

  3. Select Build | Sync Project with Gradle Files in the main menu to import the changes in the build file.

  4. Navigate to composeApp/src/commonMain/kotlin/com/example/ktor/full_stack_task_manager and create a new package called network.

  5. Inside the new package, create a new HttpClientManager.kt for the client configuration:

    package com.example.ktor.full_stack_task_manager.network import io.ktor.client.HttpClient import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json fun createHttpClient() = HttpClient { install(ContentNegotiation) { json(Json { encodeDefaults = true isLenient = true coerceInputValues = true ignoreUnknownKeys = true }) } defaultRequest { host = "1.2.3.4" port = 8080 } }

    Note you should replace 1.2.3.4 with the IP address of your current machine. You will not be able to make calls to 0.0.0.0 or localhost from code running on an Android Virtual Device or the iOS Simulator.

  6. In the same composeApp/.../full_stack_task_manager/network package, create a new TaskApi.kt file with the following implementation:

    package com.example.ktor.full_stack_task_manager.network import com.example.ktor.full_stack_task_manager.model.Task import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.delete import io.ktor.client.request.get import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.contentType class TaskApi(private val httpClient: HttpClient) { suspend fun getAllTasks(): List<Task> { return httpClient.get("tasks").body() } suspend fun removeTask(task: Task) { httpClient.delete("tasks/${task.name}") } suspend fun updateTask(task: Task) { httpClient.post("tasks") { contentType(ContentType.Application.Json) setBody(task) } } }
  7. Navigate to commonMain/.../App.kt and replace the App composable with the implementation below. This will use the TaskApi type to retrieve the list of tasks from the server, and then display the name of each one in a column:

    package com.example.ktor.full_stack_task_manager import com.example.ktor.full_stack_task_manager.network.TaskApi import com.example.ktor.full_stack_task_manager.network.createHttpClient import com.example.ktor.full_stack_task_manager.model.Task import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import kotlinx.coroutines.launch @Composable fun App() { MaterialTheme { val httpClient = createHttpClient() val taskApi = remember { TaskApi(httpClient) } val tasks = remember { mutableStateOf(emptyList<Task>()) } val scope = rememberCoroutineScope() Column( modifier = Modifier .safeContentPadding() .fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { Button(onClick = { scope.launch { tasks.value = taskApi.getAllTasks() } }) { Text("Fetch Tasks") } for (task in tasks.value) { Text(task.name) } } } }
  8. While the server is running, test the iOS application by running the iosApp run configuration.

  9. Click the Fetch Tasks button to display the list of tasks:

    App running on iOS

  10. On the Android platform, you need to explicitly give the application networking permissions and allow it to send and receive data in cleartext. To enable these permissions open composeApp/src/androidMain/AndroidManifest.xml and add the following settings:

    <manifest> ... <application android:usesCleartextTraffic="true"> ... ... </application> <uses-permission android:name="android.permission.INTERNET"/> </manifest>
  11. Run the Android application using the composeApp run configuration. You should now find that your Android client will run as well:

    App running on Android

  12. For the desktop client, you should assign dimensions and a title to the containing window. Open the file composeApp/src/desktopMain/.../main.kt and modify the code by changing the title and setting the state property:

    package com.example.ktor.full_stack_task_manager import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.application fun main() = application { val state = WindowState( size = DpSize(400.dp, 600.dp), position = WindowPosition(200.dp, 100.dp) ) Window( title = "Task Manager (Desktop)", state = state, onCloseRequest = ::exitApplication ) { App() } }
  13. Run the desktop application using the composeApp [desktop] run configuration:

    App running on desktop

  14. Run the web client using the composeApp [wasmJs] run configuration:

    App running on desktop

Improve the UI

The clients are now communicating with the server, but this is hardly an attractive UI.

  1. Open the App.kt file located in composeApp/src/commonMain/.../full_stack_task_manager and replace the existing App with the App and TaskCard composables below:

    package com.example.ktor.full_stack_task_manager import com.example.ktor.full_stack_task_manager.network.TaskApi import com.example.ktor.full_stack_task_manager.model.Priority import com.example.ktor.full_stack_task_manager.model.Task import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.ktor.full_stack_task_manager.network.createHttpClient import kotlinx.coroutines.launch @Composable fun App() { MaterialTheme { val httpClient = createHttpClient() val taskApi = remember { TaskApi(httpClient) } var tasks by remember { mutableStateOf(emptyList<Task>()) } val scope = rememberCoroutineScope() LaunchedEffect(Unit) { tasks = taskApi.getAllTasks() } LazyColumn( modifier = Modifier .safeContentPadding() .fillMaxSize() ) { items(tasks) { task -> TaskCard( task, onDelete = { scope.launch { taskApi.removeTask(it) tasks = taskApi.getAllTasks() } }, onUpdate = { } ) } } } } @Composable fun TaskCard( task: Task, onDelete: (Task) -> Unit, onUpdate: (Task) -> Unit ) { fun pickWeight(priority: Priority) = when (priority) { Priority.Low -> FontWeight.SemiBold Priority.Medium -> FontWeight.Bold Priority.High, Priority.Vital -> FontWeight.ExtraBold } Card( modifier = Modifier.fillMaxWidth().padding(4.dp), shape = RoundedCornerShape(CornerSize(4.dp)) ) { Column(modifier = Modifier.padding(10.dp)) { Text( "${task.name}: ${task.description}", fontSize = 20.sp, fontWeight = pickWeight(task.priority) ) Row { OutlinedButton(onClick = { onDelete(task) }) { Text("Delete") } Spacer(Modifier.width(8.dp)) OutlinedButton(onClick = { onUpdate(task) }) { Text("Update") } } } } }

    With this implementation, your client now has some basic functionality.

    By using the LaunchedEffect type all tasks are loaded on startup, while the LazyColumn composable allows the user to scroll through tasks.

    Finally, a separate TaskCard composable is created, which in turn uses a Card to display the details of each Task. Buttons have been added to delete and update the task.

  2. Rerun the client application — for example, the Android app. You can now scroll through tasks, view their details, and delete them:

    App running on Android with improved UI

Add update functionality

To complete the client, incorporate the functionality that would allow updating task details.

  1. Navigate to the App.kt file in composeApp/src/commonMain/.../full_stack_task_manager.

  2. Add the UpdateTaskDialog composable and necessary imports as shown below:

    import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.ui.graphics.Color import androidx.compose.ui.window.Dialog @Composable fun UpdateTaskDialog( task: Task, onConfirm: (Task) -> Unit ) { var description by remember { mutableStateOf(task.description) } var priorityText by remember { mutableStateOf(task.priority.toString()) } val colors = TextFieldDefaults.colors( focusedTextColor = Color.Blue, focusedContainerColor = Color.White, ) Dialog(onDismissRequest = {}) { Card( modifier = Modifier.fillMaxWidth().padding(4.dp), shape = RoundedCornerShape(CornerSize(4.dp)) ) { Column(modifier = Modifier.padding(10.dp)) { Text("Update ${task.name}", fontSize = 20.sp) TextField( value = description, onValueChange = { description = it }, label = { Text("Description") }, colors = colors ) TextField( value = priorityText, onValueChange = { priorityText = it }, label = { Text("Priority") }, colors = colors ) OutlinedButton(onClick = { val newTask = Task( task.name, description, try { Priority.valueOf(priorityText) } catch (e: IllegalArgumentException) { Priority.Low } ) onConfirm(newTask) }) { Text("Update") } } } } }

    This is a composable that displays the details of a Task with a dialog box. The description and priority are placed within TextField composables so they can be updated. When the user presses the update button, it triggers the onConfirm() callback.

  3. Update the App composable in the same file:

    @Composable fun App() { MaterialTheme { val httpClient = createHttpClient() val taskApi = remember { TaskApi(httpClient) } var tasks by remember { mutableStateOf(emptyList<Task>()) } val scope = rememberCoroutineScope() var currentTask by remember { mutableStateOf<Task?>(null) } LaunchedEffect(Unit) { tasks = taskApi.getAllTasks() } if (currentTask != null) { UpdateTaskDialog( currentTask!!, onConfirm = { scope.launch { taskApi.updateTask(it) tasks = taskApi.getAllTasks() } currentTask = null } ) } LazyColumn(modifier = Modifier .safeContentPadding() .fillMaxSize() ) { items(tasks) { task -> TaskCard( task, onDelete = { scope.launch { taskApi.removeTask(it) tasks = taskApi.getAllTasks() } }, onUpdate = { currentTask = task } ) } } } }

    You are storing an additional piece of state, which is the current task selected. If this value is not null, then we invoke our UpdateTaskDialog composable, with the onConfirm() callback set to send a POST request to the server using the TaskApi.

    Finally, when you are creating the TaskCard composables, you use the onUpdate() callback to set the currentTask state variable.

  4. Rerun the client application. You should now be able to update the details of each task by using the buttons.

    Deleting tasks on Android

Next steps

In this article, you have used Ktor within the context of a Kotlin Multiplatform application. You can now create a project containing multiple services and clients, targeting a range of different platforms.

As you have seen, it is possible to build out features without any code duplication or redundancy. The types required in all layers of the project can be placed within the shared multiplatform module. Functionality which is only required by the services goes in the server module, whilst functionality only required by the clients is placed in composeApp.

This kind of development inevitably requires knowledge of both client and server technologies. But you can use Kotlin Multiplatform libraries and Compose Multiplatform to minimize the amount of new material you need to learn. Even if your focus is initially only on a single platform, you can easily add others as demand for your application grows.

11 July 2025