Going Full-Stack With Kotlin/JS and Spring Boot
In this article, explore Kotlin/JS for creating a web application that communicates with a Spring Boot backend which is also written in Kotlin.
Join the DZone community and get the full member experience.
Join For FreeIn the dynamic world of web development, Single Page Applications (SPAs) and frameworks like React, Angular, and Vue.js have emerged as the preferred approach for delivering seamless user experiences. With the evolution of the Kotlin language and its recent multiplatform capabilities, new options exist that are worthwhile to evaluate.
In this article, we will explore Kotlin/JS for creating a web application that communicates with a Spring Boot backend which is also written in Kotlin. In order to keep it as simple as possible, we will not bring in any other framework.
Advantages of Kotlin/JS for SPA Development
As described in the official documentation, Kotlin/JS provides the ability to transpile Kotlin code, the Kotlin standard library, and any compatible dependencies to JavaScript (ES5). With Kotlin/JS we can manipulate the DOM and create dynamic HTML by taking advantage of Kotlin's conciseness and expressiveness, coupled with its compatibility with JavaScript. And of course, we do have the much needed type-safety, which reduces the likelihood of runtime errors.
This enables developers to write client-side code with reduced boilerplate and fewer errors. Additionally, Kotlin/JS seamlessly integrates with popular JavaScript libraries (and frameworks), thus leveraging the extensive ecosystem of existing tools and resources. And, last but not least: this makes it easier for a backend developer to be involved with the frontend part as it looks more familiar. Moderate knowledge of "vanilla" JavaScript, the DOM, and HTML is of course needed; but especially when we are dealing with non-intensive apps (admin panels, back-office sites, etc.), one can get engaged rather smoothly.
Sample Project
The complete source code for this showcase is available on GitHub. The backend utilizes Spring Security for protecting a simple RESTful API with basic CRUD operations. We won't expand more on this since we want to keep the spotlight on the frontend part which demonstrates the following:
- Log in with username/password
- Cookie-based session
- Page layout with multiple tabs and top navigation bar (based on Bootstrap)
- Client-side routing (based on Navigo)
- Table with pagination, sorting, and filtering populated with data fetched from the backend (based on DataTables)
- Basic form with input fields including (dependent) drop-down lists (based on Bootstrap)
- Modals and loading masks (based on Bootstrap and spin.js)
- Usage of sessionStorage and localStorage
- Usage of Ktor HttpClient for making HTTP calls to the backend
An architectural overview is provided in the diagram below:
Starting Point
The easiest way to start exploring is by creating a new Kotlin Multiplatform project from IntelliJ. The project's template must be "Full-Stack Web Application":
This will create the following project structure:
springMain
: This is the module containing the server-side implementation.springTest
: For the Spring Boot testscommonMain
: This module contains "shared" code between the frontend and the backend; e.g., DTOscommonTest
: For the unit tests of the "common" modulejsMain
: This is the frontend module responsible for our SPA.jsTest
: For the Kotlin/JS tests
The sample project on GitHub is based on this particular skeleton. Once you clone the project you may start the backend by executing:
$ ./gradlew bootRun
This will spin up the SpringBoot app, listening on port: 8090. In order to start the frontend, execute:
$ ./gradlew jsBrowserDevelopmentRun -t
This will open up a browser window automatically navigating to http://localhost:8080 and presenting the user login page. For convenience, a couple of users are provisioned on the server (have a look at dev.kmandalas.demo.config.SecurityConfig for details).
Once logged in, the user views a group of tabs with the main tab presenting a table (data grid) with items fetched from the server. The user can interact with the table (paging, sorting, filtering, data export) and add a new item (product) by pressing the "Add product" button. In this case, a form is presented within a modal with typical input fields and dependent drop-down lists with data fetched from the server. In fact, there is some caching applied on this part in order to reduce network calls.
Finally, from the top navigation bar, the user can toggle the theme (this setting is preserved in the browser's local storage) and perform logout. In the next section, we will explore some low-level details for selected parts of the frontend module.
The jsMain Module
Let's start by having a look at the structure of the module:
The naming of the Kotlin files should give an idea about the responsibility of each class. The "entrypoint" is of course the Main.kt class:
import home.Layout
import kotlinx.browser.window
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
fun main() {
MainScope().launch {
window.onload = {
Layout.init()
val router = Router()
router.start()
}
}
}
Once the "index.html" file is loaded, we initialize the Layout
and our client-side Router
.
Now, the "index.html" imports the JavaScript source files of the things we use (Bootstrap, Navigo, Datatables, etc.) and their corresponding CSS files. And of course, it imports the "transpiled" JavaScript file of our Kotlin/JS application. Apart from this, the HTML body part consists of some static parts like the "Top Navbar," and most importantly, our root HTML div tag. Under this tag, we will perform the DOM manipulations needed for our simple SPA.
By importing the kotlinx.browser
package in our Kotlin classes and singletons, we have access to top-level objects such as the document
and window
. The standard library provides typesafe wrappers for the functionality exposed by these objects (wherever possible) as described in the Browser and DOM API.
So this is what we do at most parts of the module by writing Kotlin and not JavaScript or using jQuery, and at the same time having type-safety without using, e.g., TypeScript. So for example we can create content like this:
private fun buildTable(products: List<Product>): HTMLTableElement {
val table = document.createElement("table") as HTMLTableElement
table.className = "table table-striped table-hover"
// Header
val thead = table.createTHead()
val headerRow = thead.insertRow()
headerRow.appendChild(document.createElement("th").apply { textContent = "ID" })
headerRow.appendChild(document.createElement("th").apply { textContent = "Name" })
headerRow.appendChild(document.createElement("th").apply { textContent = "Category" })
headerRow.appendChild(document.createElement("th").apply { textContent = "Price" })
// Body
val tbody = table.createTBody()
for (product in products) {
val row = tbody.insertRow()
row.appendChild(document.createElement("td").apply { textContent = product.id.toString() })
row.appendChild(document.createElement("td").apply { textContent = product.name })
row.appendChild(document.createElement("td").apply { textContent = product.category.name })
row.appendChild(document.createElement("td").apply { textContent = product.price.toString() })
}
document.getElementById("root")?.appendChild(table)
return table
}
Alternatively, we can use the Typesafe HTML DSL of the kotlinx.html
library which looks pretty cool. Or we can load HTML content as "templates" and further process them. Seems that many possibilities exist for this task.
Moving on, we can attach event-listeners thus dynamic behavior to our UI elements like this:
categoryDropdown?.addEventListener("change", {
val selectedCategory = categoryDropdown.value
// Fetch sub-categories based on the selected category
mainScope.launch {
populateSubCategories(selectedCategory)
}
})
Before talking about some "exceptions to the rule", it's worth mentioning that we use the Ktor HTTP client (see ProductApi) for making the REST calls to the backend. We could use the ported Fetch API for this task but going with the client looks way better. Of course, we need to add the ktor-client
as a dependency to the build.gradle.kts
file:
val jsMain by getting {
dependsOn(commonMain)
dependencies {
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-js:$ktorVersion")
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
//...
}
}
The client includes the JSESSIONID browser cookie received from the server upon successful authentication of the HTTP requests. If this is omitted, we will get back HTTP 401/403 errors from the server. These are also handled and displayed within Bootstrap modals. Also, a very convenient thing regarding the client-server communication is the sharing of common data classes (Product.kt and Category.kt, in our case) between the jsMain
and springMain
modules.
Exception 1: Use Dependencies From npm
For client-side routing, we selected the Navigo JavaScript library. This library is not part of Kotlin/JS, but we can import it in Gradle using the npm
function:
val jsMain by getting {
dependsOn(commonMain)
dependencies {
//...
implementation(npm("navigo", "8.11.1"))
}
}
However, because JavaScript modules are dynamically typed and Kotlin is statically typed, in order to manipulate Navigo from Kotlin we have to provide an "adapter." This is what we do within the Router.kt class:
@JsModule("navigo")
@JsNonModule
external class Navigo(root: String, resolveOptions: ResolveOptions = definedExternally) {
fun on(route: String, handler: () -> Unit)
fun resolve()
fun navigate(s: String)
}
With this in place, the Navigo JavaScript module can be used just like a regular Kotlin class.
Exception 2: Use JavaScript Code From Kotlin
It is possible to invoke JavaScript functions from Kotlin code using the js()
function. Here are some examples from our example project:
// From ProductTable.kt:
private fun initializeDataTable() {
js("new DataTable('#$PRODUCTS_TABLE_ID', $DATATABLE_OPTIONS)")
}
// From ModalUtil.kt:
val modalElement = document.getElementById(modal.id) as? HTMLDivElement
modalElement?.let {
js("new bootstrap.Modal(it).show()")
}
However, this should be used with caution since this way we are outside Kotlin's type system.
Takeaways
In general, the best framework to choose depends on several factors with one of the most important ones being, "The one that the developer team is more familiar with." On the other hand, according to Thoughtworks Technology radar, the SPA by default approach is under question; meaning, that we should not blindly accept the complexity of SPAs and their frameworks by default even when the business needs don't justify it.
In this article, we provided an introduction to Kotlin multiplatform with Kotlin/JS which brings new things to the table. Taking into consideration the latest additions in the ecosystem - namely Kotlin Wasm and Compose Multiplatform - it becomes evident that these advancements offer not only a fresh perspective but also robust solutions for streamlined development.
Opinions expressed by DZone contributors are their own.
Comments