Ktor REST API Project — Complete Backend Tutorial
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/registerandPOST /api/auth/loginfor user managementGET /api/booksandGET /api/books/{id}for reading booksPOST /api/books,PUT /api/books/{id},DELETE /api/books/{id}for managing booksGET /api/books/{id}/reviewsandPOST /api/books/{id}/reviewsfor 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
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.
Forgetting to install the content negotiation plugin: Without
ContentNegotiation,call.receive<T>()andcall.respond(T)will not know how to serialize objects. The server will return a 500 error with no JSON support.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.
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.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
- How does Ktor's routing DSL differ from Spring Boot's annotation-based routing?
- Why should you use
Dispatchers.IOfor blocking database calls in Ktor routes? - How does the JWT authentication plugin verify tokens on each request?
- What is the purpose of the content negotiation plugin in Ktor?
- Challenge: Add pagination support to the
GET /api/booksendpoint using query parameters. Return aPageobject withitems,totalCount, andhasMorefields.
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
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