GuidesJava
Ktor API with Kotlin and Gradle
Create a Kotlin HTTP API with Ktor 3, Netty, kotlinx.serialization, CORS, the Gradle wrapper, and a Ktor test host test using Better Fullstack.
Use this stack when you want a small Kotlin HTTP service on the JVM, with explicit routing and plugins instead of a dependency-injection framework.
npm create better-fullstack@latest my-ktor-api -- \ --ecosystem java \ --java-language kotlin \ --java-web-framework ktor \ --java-build-tool gradle \ --java-orm none \ --java-auth none \ --java-api none \ --java-libraries none \ --java-testing-libraries junit5What this creates
- A Ktor 3 server on the Netty engine, written in Kotlin 2.
- The
ContentNegotiationplugin with kotlinx.serialization JSON, and theCORSplugin. - Two routes:
GET /returns a JSON message andGET /healthreturns{"status":"ok"}. - A Gradle Kotlin DSL build with the
applicationplugin, a JVM 21 toolchain, and the Gradle wrapper. - A test that runs the application module in Ktor's
testApplicationhost.
Generated shape
my-ktor-api/
├── build.gradle.kts
├── settings.gradle.kts
├── gradlew
├── gradlew.bat
├── gradle/wrapper/
├── .env.example
└── src/
├── main/kotlin/com/betterfullstack/server/Application.kt
└── test/kotlin/com/betterfullstack/server/ApplicationTest.ktThe package is always com.betterfullstack.server, and settings.gradle.kts names the Gradle project my-ktor-api-server. Rename both when the service gets its real identity.
Prerequisites
- A JDK 21 installation. The build declares
jvmToolchain(21). - No separate Gradle install; the wrapper downloads the version pinned in
gradle/wrapper/gradle-wrapper.properties.
Run and verify
cd my-ktor-api
./gradlew test
./gradlew runThe server binds 0.0.0.0 on PORT, or 8080 when PORT is unset:
curl http://localhost:8080/health.env.example lists PORT and CORS_ORIGIN, but the application reads only real environment variables through System.getenv. Export them in your shell or process manager; nothing loads a .env file.
Representative snippets
main starts the embedded server and Application.module() installs plugins and routes:
fun main() {
val port = System.getenv("PORT")?.toIntOrNull() ?: 8080
embeddedServer(Netty, port = port, host = "0.0.0.0", module = Application::module).start(wait = true)
}
fun Application.module() {
install(ContentNegotiation) { json() }
install(CORS) {
allowMethod(HttpMethod.Get)
allowHeader(HttpHeaders.ContentType)
val origin = System.getenv("CORS_ORIGIN")
if (origin.isNullOrBlank()) anyHost() else allowHost(origin.removePrefix("http://").removePrefix("https://"), schemes = listOf("http", "https"))
}
routing {
get("/") { call.respond(mapOf("message" to "my-ktor-api Ktor API")) }
get("/health") { call.respond(HttpStatusCode.OK, mapOf("status" to "ok")) }
}
}Because the module is an extension function, the test boots the same configuration without opening a port:
@Test fun healthEndpointIsReady() = testApplication {
application { module() }
assertEquals(HttpStatusCode.OK, client.get("/health").status)
}Growing the service
The CORS block calls allowMethod(HttpMethod.Get) and allows the Content-Type header. Ktor's CORS plugin also allows POST and HEAD by default, so preflight requests for other methods return 403. Add allowMethod(HttpMethod.Put), allowMethod(HttpMethod.Delete), or allowMethod(HttpMethod.Patch) before browser clients send those methods.
For request and response bodies, add @Serializable data classes. The kotlinx.serialization Gradle plugin is not applied in the generated build. Without it, annotated classes still compile, but responding with one fails at runtime because no serializer is generated. Add the plugin next to kotlin("jvm"):
plugins {
kotlin("jvm") version "2.3.21"
kotlin("plugin.serialization") version "2.3.21"
application
}Keep the plugin version equal to the Kotlin version.
When to choose it
Choose Ktor for a lightweight Kotlin service where you want to see every plugin and route in code. Choose Spring Boot with PostgreSQL and JPA when you need Spring Data, Spring Security, and the wider Spring library set, which Better Fullstack only wires for Spring Boot.
Compatibility notes
- Ktor uses its own template.
--java-orm,--java-auth,--java-api, and--java-librariesare Spring Boot features, so set each tonone. When they are omitted, the CLI stops with "Spring-only Java features require the Spring Boot scaffold". --java-build-tool mavengenerates apom.xmland the Maven wrapper instead of the Gradle files.- The test runs on the JUnit Platform through
kotlin("test"). The--java-testing-librariesselection does not add other test files to the Ktor template.
Deployment notes
./gradlew build compiles and tests the service. The application plugin also provides ./gradlew installDist, which writes start scripts under build/install/. No Dockerfile is generated for Ktor, so package it with your own image or platform buildpack, and set PORT and CORS_ORIGIN in that environment.
Troubleshooting
| Symptom | Check |
|---|---|
| CLI reports "Spring-only Java features" | Pass none for --java-orm, --java-auth, --java-api, and --java-libraries. |
Browser PUT or DELETE requests fail CORS | The generated CORS plugin allows GET, POST, and HEAD; add the other methods. |
CORS_ORIGIN from .env has no effect | Export it in the environment; the app does not read .env. |
Serializer for class '...' is not found | Apply the plugin.serialization Gradle plugin. |
The README mentions plain Java and src/main/java | The generated README describes a plain Java project; the Kotlin sources live under src/main/kotlin. |
Next steps
- Open the Stack Builder.
- Read the Java ecosystem docs.
- Compare with Quarkus REST API with Maven.