Skip to content

Ktor REST API Project — Complete Backend Tutorial

DodaTech Updated 2026-06-28 10 min read

In this tutorial, you will learn about Ktor REST API Project. We cover key concepts, practical examples, and best practices to help you master this topic.

Ktor is a lightweight asynchronous framework for building server-side applications in Kotlin, using coroutines for concurrency and offering a modular plugin system for serialization, authentication, content negotiation, and database access.

What You Will Learn

  • Setting up a Ktor project with Gradle and the Ktor server engine
  • Defining RESTful routes using the Ktor routing DSL
  • Serializing and deserializing JSON with kotlinx.serialization
  • Implementing content negotiation, logging, and status pages
  • Connecting to a database with Exposed ORM
  • Securing endpoints with JWT authentication
  • Structuring a production-ready Ktor application

Why It Matters

Spring Boot dominates the Java backend world, but its annotation-heavy configuration and slower startup make it less ideal for microservices and lightweight APIs. Ktor offers a Kotlin-native alternative built on coroutines from the ground up. Routes are defined with a concise DSL, request handling is naturally async, and the server starts in under a second. Understanding Ktor gives you a modern, high-performance option for building REST APIs, GraphQL endpoints, and WebSocket servers.

Real-World Use

The DodaTech code execution API is built with Ktor. When a user runs code in the tutorial sandbox, the request hits a Ktor endpoint that spawns a Docker container, captures stdout, and returns the output. The entire request lifecycle is handled with coroutines, allowing hundreds of concurrent executions without blocking threads.

Learning Path

flowchart LR
  A[Ktor Basics + KMP] --> B[Ktor REST API Project\nYou are here]
  B --> C[Testing + Dependency Injection]
  style B fill:#f90,color:#fff

Project Overview

We will build a Book Review API that supports CRUD operations for books and reviews, with JWT-based authentication. The API will expose the following endpoints:

  • POST /api/auth/register and POST /api/auth/login for user management
  • GET /api/books and GET /api/books/{id} for reading books
  • POST /api/books, PUT /api/books/{id}, DELETE /api/books/{id} for managing books
  • GET /api/books/{id}/reviews and POST /api/books/{id}/reviews for reviews

The project will use Exposed ORM for database access, kotlinx.serialization for JSON, and the Ktor JWT plugin for authentication. The architecture follows a clean layered structure: routes handle HTTP concerns, services contain business logic, and repositories access the database.

Setting Up the Project

Create a new Gradle project with Kotlin DSL. The build.gradle.kts file includes the Ktor server engine, serialization, authentication, and database dependencies:

// build.gradle.kts
plugins {
    kotlin("jvm") version "1.9.22"
    kotlin("plugin.serialization") version "1.9.22"
}

application {
    mainClass.set("com.dodatech.bookapi.ApplicationKt")
}

dependencies {
    // Ktor server
    implementation("io.ktor:ktor-server-core:2.3.7")
    implementation("io.ktor:ktor-server-netty:2.3.7")
    implementation("io.ktor:ktor-server-content-negotiation:2.3.7")
    implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")
    implementation("io.ktor:ktor-server-auth:2.3.7")
    implementation("io.ktor:ktor-server-auth-jwt:2.3.7")
    implementation("io.ktor:ktor-server-status-pages:2.3.7")
    implementation("io.ktor:ktor-server-call-logging:2.3.7")

    // Serialization
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")

    // Database
    implementation("org.jetbrains.exposed:exposed-core:0.44.1")
    implementation("org.jetbrains.exposed:exposed-dao:0.44.1")
    implementation("org.jetbrains.exposed:exposed-jdbc:0.44.1")
    implementation("com.h2database:h2:2.2.224")

    // Dependency injection
    implementation("io.insert-koin:koin-ktor:3.5.3")

    // Logging
    implementation("ch.qos.logback:logback-classic:1.4.14")

    // Testing
    testImplementation("io.ktor:ktor-server-test-host:2.3.7")
    testImplementation("org.jetbrains.kotlin:kotlin-test:1.9.22")
}

Ktor provides different server engines (Netty, Jetty, CIO). Netty is the default and works well for most use cases. The content-negotiation plugin handles automatic JSON serialization, while the auth plugin provides JWT verification.

Defining Data Models

Use kotlinx.serialization to define request and response data classes:

// model/Models.kt
import kotlinx.serialization.Serializable

@Serializable
data class Book(
    val id: Int = 0,
    val title: String,
    val author: String,
    val isbn: String,
    val year: Int
)

@Serializable
data class Review(
    val id: Int = 0,
    val bookId: Int,
    val userId: Int,
    val rating: Int,
    val comment: String
)

@Serializable
data class User(
    val id: Int = 0,
    val username: String,
    val password: String
)

@Serializable
data class AuthRequest(val username: String, val password: String)

@Serializable
data class AuthResponse(val token: String)

@Serializable from kotlinx.serialization enables automatic JSON serialization without Reflection. Ktor's content-negotiation plugin will convert these objects to and from JSON in request bodies and responses.

Configuring the Ktor Application

The application entry point configures plugins and starts the server:

// Application.kt
import com.dodatech.bookapi.plugins.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
        configureSerialization()
        configureAuth()
        configureStatusPages()
        configureCallLogging()
        configureRouting()
    }.start(wait = true)
}

Each plugin function extends the Application receiver and installs middleware. This modular approach keeps the main function clean and makes it easy to enable or disable features for different environments.

Implementing Plugins

The serialization plugin registers kotlinx.serialization as the JSON codec:

// plugins/Serialization.kt
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import kotlinx.serialization.json.Json

fun Application.configureSerialization() {
    install(ContentNegotiation) {
        json(Json {
            prettyPrint = true
            isLenient = true
            ignoreUnknownKeys = true
        })
    }
}

ignoreUnknownKeys prevents deserialization failures when the API evolves. The status pages plugin returns structured JSON error responses:

// plugins/StatusPages.kt
import io.ktor.server.application.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.response.*
import io.ktor.http.*

fun Application.configureStatusPages() {
    install(StatusPages) {
        exception<Throwable> { call, cause ->
            call.respondText(
                contentType = ContentType.Application.Json,
                status = HttpStatusCode.InternalServerError,
                text = """{"error":"${cause.message}"}"""
            )
        }
    }
}

Setting Up Authentication with JWT

The JWT authentication plugin verifies tokens from the Authorization header. Secret keys should come from environment variables in production:

// plugins/Auth.kt
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import java.util.*

fun Application.configureAuth() {
    val secret = System.getenv("JWT_SECRET") ?: "default-secret-change-in-production"
    val issuer = "dodatech"
    val audience = "book-api"

    install(Authentication) {
        jwt("auth-jwt") {
            verifier(
                JWT.require(Algorithm.HMAC256(secret))
                    .withIssuer(issuer)
                    .withAudience(audience)
                    .build()
            )
            validate { credential ->
                if (credential.payload.getClaim("username").asString() != null) {
                    JWTPrincipal(credential.payload)
                } else null
            }
        }
    }
}

Tokens are generated during login by signing a payload containing the username and expiration time. The validate lambda returns a JWTPrincipal if the token is valid, which is then accessible inside authenticated routes.

Defining Routes

The routing DSL groups endpoints by resource. Here is the book routes module:

// routes/BookRoutes.kt
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Route.bookRoutes() {
    val service = BookService()

    route("/api/books") {
        get {
            val year = call.request.queryParameters["year"]?.toIntOrNull()
            val books = if (year != null) service.getBooksByYear(year)
                         else service.getAllBooks()
            call.respond(books)
        }

        get("/{id}") {
            val id = call.parameters["id"]?.toIntOrNull()
                ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid ID")
            val book = service.getBook(id)
            if (book != null) call.respond(book)
            else call.respond(HttpStatusCode.NotFound, "Book not found")
        }

        authenticate("auth-jwt") {
            post {
                val book = call.receive<Book>()
                val created = service.createBook(book)
                call.respond(HttpStatusCode.Created, created)
            }

            put("/{id}") {
                val id = call.parameters["id"]?.toIntOrNull()
                    ?: return@put call.respond(HttpStatusCode.BadRequest, "Invalid ID")
                val book = call.receive<Book>()
                val updated = service.updateBook(id, book)
                if (updated != null) call.respond(updated)
                else call.respond(HttpStatusCode.NotFound, "Book not found")
            }

            delete("/{id}") {
                val id = call.parameters["id"]?.toIntOrNull()
                    ?: return@delete call.respond(HttpStatusCode.BadRequest, "Invalid ID")
                service.deleteBook(id)
                call.respond(HttpStatusCode.NoContent)
            }
        }
    }
}

Unprotected endpoints (GET) are public. Write endpoints (POST, PUT, DELETE) are wrapped in authenticate("auth-jwt"), which extracts and validates the JWT from the Authorization header. call.receive<T>() deserializes the request body into the specified type using the content negotiation plugin.

Implementing the Service Layer

The service layer contains business logic and delegates database access to repositories:

// service/BookService.kt
class BookService {
    private val repository = BookRepository()

    fun getAllBooks(): List<Book> = repository.findAll()
    fun getBook(id: Int): Book? = repository.findById(id)
    fun getBooksByYear(year: Int): List<Book> = repository.findByYear(year)
    fun createBook(book: Book): Book = repository.save(book)
    fun updateBook(id: Int, book: Book): Book? {
        return if (repository.exists(id)) repository.save(book.copy(id = id))
        else null
    }
    fun deleteBook(id: Int) = repository.delete(id)
}
}

Separating routes, services, and repositories keeps each layer testable in isolation. Routes only handle HTTP concerns. Services contain business rules, such as validating that a book's year is not in the future.

## Database Access with Exposed

Exposed provides a type-safe SQL DSL. Tables are defined as Kotlin objects:

```kotlin
// database/Tables.kt
import org.jetbrains.exposed.sql.Table

object BooksTable : Table("books") {
    val id = integer("id").autoIncrement()
    val title = varchar("title", 255)
    val author = varchar("author", 255)
    val isbn = varchar("isbn", 20).uniqueIndex()
    val year = integer("year")

    override val primaryKey = PrimaryKey(id)
}

object ReviewsTable : Table("reviews") {
    val id = integer("id").autoIncrement()
    val bookId = integer("book_id").references(BooksTable.id)
    val userId = integer("user_id")
    val rating = integer("rating")
    val comment = varchar("comment", 1000)

    override val primaryKey = PrimaryKey(id)
}

The repository executes queries using Exposed's transaction block:

// database/BookRepository.kt
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.Transaction

class BookRepository {
    fun findAll(): List<Book> = transaction {
        BooksTable.selectAll().map { it.toBook() }
    }

    fun findById(id: Int): Book? = transaction {
        BooksTable.selectAll().where { BooksTable.id eq id }
            .singleOrNull()?.toBook()
    }

    fun save(book: Book): Book = transaction {
        val insert = BooksTable.insert {
            it[title] = book.title
            it[author] = book.author
            it[isbn] = book.isbn
            it[year] = book.year
        }
        book.copy(id = insert[BooksTable.id])
    }

    fun delete(id: Int) = transaction {
        BooksTable.deleteWhere { BooksTable.id eq id }
    }

    fun exists(id: Int): Boolean = transaction {
        BooksTable.selectAll().where { BooksTable.id eq id }.count() > 0
    }
}

Exposed's transaction block manages the database connection and automatically commits or rolls back. The toBook() extension function maps a result row to the Book data class.

Wiring Everything Together

The routing module connects routes and starts the server:

// plugins/Routing.kt
import io.ktor.server.application.*
import io.ktor.server.routing.*

fun Application.configureRouting() {
    routing {
        bookRoutes()
        reviewRoutes()
        authRoutes()
    }
}

Initialize the database connection in the main application:

// Database.kt (called from main)
import org.jetbrains.exposed.sql.Database

fun initDatabase() {
    Database.connect(
        url = "jdbc:h2:file:./data/bookdb",
        driver = "org.h2.Driver",
        user = "sa",
        password = ""
    )
}

Testing the API

Start the server and test endpoints with curl:

curl -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"secret123"}'

curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"secret123"}'
# Returns: {"token":"eyJhbGciOiJIUzI1NiIs..."}

curl -X POST http://localhost:8080/api/books \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"title":"1984","author":"George Orwell","isbn":"9780451524935","year":1949}'
# Returns: {"id":1,"title":"1984","author":"George Orwell","isbn":"9780451524935","year":1949}

curl http://localhost:8080/api/books
# Returns: [{"id":1,"title":"1984","author":"George Orwell","isbn":"9780451524935","year":1949}]

Common Mistakes

  1. Not handling Coroutine exceptions in routes: Ktor routes run in coroutines. Uncaught exceptions will crash the coroutine scope. Always use the status pages plugin or try-catch blocks to return meaningful HTTP error responses.

  2. Forgetting to install the content negotiation plugin: Without ContentNegotiation, call.receive<T>() and call.respond(T) will not know how to serialize objects. The server will return a 500 error with no JSON support.

  3. Hardcoding secrets in source code: JWT secrets, database passwords, and API keys must come from environment variables or a secrets manager. Committing secrets to version control is a security risk.

  4. Blocking the event loop with synchronous database calls: Ktor is asynchronous. Database queries using JDBC block the thread. Wrap database transactions in withContext(Dispatchers.IO) or use a reactive driver like R2DBC to avoid blocking the Netty event loop.

  5. Missing CORS configuration for frontend clients: If your API serves a web frontend, you need to install the CORS plugin and allow the frontend origin. Without it, browsers will block cross-origin requests.

Practice Questions

  1. How does Ktor's routing DSL differ from Spring Boot's annotation-based routing?
  2. Why should you use Dispatchers.IO for blocking database calls in Ktor routes?
  3. How does the JWT authentication plugin verify tokens on each request?
  4. What is the purpose of the content negotiation plugin in Ktor?
  5. Challenge: Add pagination support to the GET /api/books endpoint using query parameters. Return a Page object with items, totalCount, and hasMore fields.

Mini Project

Build a Task Manager API with Ktor that supports:

  • User registration and login with JWT
  • CRUD operations for tasks (title, description, due date, priority, status)
  • Filtering tasks by status (pending, in-progress, completed)
  • Pagination with page and limit parameters
  • Swagger documentation using the Ktor OpenAPI plugin

FAQ

How does Ktor handle concurrent requests?

Ktor is built on coroutines. Each request runs in a separate coroutine on the Netty event loop. The event loop can handle thousands of concurrent connections without creating a thread per request.

Can I use Ktor with a relational database?

Yes. Ktor integrates with Exposed ORM or any JDBC-compatible library. For non-blocking access, consider R2DBC with the Ktor client or a reactive driver.

How do I deploy a Ktor application?

Ktor applications can be packaged as fat JARs using the Shadow plugin, deployed to Docker containers, or run on platforms like Heroku, AWS Elastic Beanstalk, or Google Cloud Run.

What is the difference between Ktor client and Ktor server?

Ktor client is an HTTP client library for making requests to external APIs. Ktor server is the HTTP server framework. Both share the same plugin architecture and coroutine-based design.

How do I add rate limiting to Ktor routes?

Install the ktor-server-rate-limit plugin or implement a custom plugin that tracks request counts per IP using a fixed window or sliding window algorithm.

What is Next

Now that you have built a Ktor REST API, you are ready to connect it to a frontend client. Learn Kotlin Multiplatform to share API models between mobile and web, or dive into Testing with Kotest for writing integration tests that verify your API endpoints. You can also explore Dependency Injection with Koin to structure larger applications.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro