Upgrade to Pro — share decks privately, control downloads, hide ads and more …

A full stack side project webapp all in Kotlin ...

A full stack side project webapp all in Kotlin (KotlinConf 2025)

It's happened to all of us – an idea strikes for a side project and you want to build a quick webapp, but then you remember the state of webdev is still a bit of a mess in 2025. Sure you could pick up one of those "all-in-one" frameworks, but they're so heavy and complicated. And even if that worked, you'd still need something on the frontend so you start looking at the the weird world of frontend frameworks or (gasp!) writing raw HTML/CSS/JavaScript.

But it doesn't have to be that hard! I'm here to present a straightforward stack that gets you a full stack webapp up and running fast, using the one language we all know and love – Kotlin. Database manipulation, async services, and a high fidelity UI, all accessed and built with Kotlin. And yes, you read that right – you can have a great UI without having to write a single line of CSS, HTML, or JavaScript.

I'll show you a path for building a basic webapp on top of a simple database (Postgres), performing basic CRUD operations on that database (Kotlin Exposed), building business logic via async services (Kotlin Coroutines), and constructing a high-fidelity UI to bring it all together (Vaadin on Kotlin). By the end of the talk we'll have a fully functional webapp that is entirely built on Kotlin.

And let's not forget, we're not building a by-the-book, very serious webapp, just something we're doing on the side for fun. So forget about tests and forget about perfect architecture – let's have some fun, learn a few things along the way, and get this thing running!

Avatar for Dan Kim

Dan Kim

July 04, 2025
Tweet

More Decks by Dan Kim

Other Decks in Programming

Transcript

  1. dependencies { // vaadin implementation("com.vaadin:vaadin-core:24.7.2") implementation("com.github.mvysny.vaadin-boot:vaadin-boot:13.3") implementation("com.github.mvysny.karibudsl:karibu-dsl:2.3.2") // exposed implementation("org.jetbrains.exposed:exposed-core:0.61.0")

    implementation("org.jetbrains.exposed:exposed-java-time:0.61.0") implementation("org.jetbrains.exposed:exposed-jdbc:0.61.0") // database implementation("com.zaxxer:HikariCP:6.3.0") implementation("org.postgresql:postgresql:42.7.5") }
  2. object Todos : IntIdTable("todos") { val title = varchar("title", 255).nullable()

    val description = varchar("description", 255).nullable() val dueAt = date("due_at").nullable() val completedAt = date("completed_at").nullable() val userId = reference("user_id", Users).nullable() } object Users : IntIdTable("users") { val name = varchar("name", 255).nullable() }
  3. data class Todo( val id: Int? = null, val title:

    String? = null, val notes: String? = null, val dueAt: LocalDate? = null, val completedAt: LocalDate? = null, val user: User? = null ) data class User( val id: Int? = null, val name: String? = null )
  4. val dataSource = HikariDataSource(HikariConfig().apply { jdbcUrl = "jdbc:postgresql://localhost:5432/" username =

    "password" password = "password" driverClassName = "org.postgresql.Driver" }) val database = Database.connect(dataSource)
  5. UI

  6. class MainLayout : KComposite(), RouterLayout { private val root =

    ui { verticalLayout { div { h1(“Todos") } } } override fun showRouterLayoutContent(content: HasElement) { root.add(content as Component) } }
  7. @Route(value = "", layout = MainLayout::class) class TodoList : KComposite()

    { private val currentUi = UI.getCurrent() private val viewModel = TodoViewModel() private lateinit var grid: Grid<Todo> private val root = ui { verticalLayout { grid = grid { setDefaults() column({ it.title }).setHeader("Todo") column({ it.dueAt?.toString() }).setHeader("Due Date") column({ it.user?.name }).setHeader("Assignee") } } } init { configObservers() viewModel.fetchAll() } private fun configObservers() { viewModel.todos.observe { currentUi.accessSynchronously { grid.setItems(it) } } } } }
  8. @Route(value = "", layout = MainLayout::class) class TodoList : KComposite()

    { private val currentUi = UI.getCurrent() private val viewModel = TodoViewModel() private lateinit var grid: Grid<Todo> private val root = ui { verticalLayout { grid = grid { setDefaults() column({ it.title }).setHeader("Todo") column({ it.dueAt?.toString() }).setHeader("Due Date") column({ it.user?.name }).setHeader("Assignee") } } } init { configObservers() viewModel.fetchAll() } private fun configObservers() { viewModel.todos.observe { currentUi.accessSynchronously { grid.setItems(it) } } } } }
  9. private lateinit var grid: Grid<Todo> private val root = ui

    { verticalLayout { grid = grid { setDefaults() column({ it.title }).setHeader("Todo") column({ it.dueAt?.toString() }).setHeader("Due Date") column({ it.user?.name }).setHeader("Assignee") } } } }
  10. @Route(value = "", layout = MainLayout::class) class TodoList : KComposite()

    { private val currentUi = UI.getCurrent() private val viewModel = TodoViewModel() private lateinit var grid: Grid<Todo> private val root = ui { verticalLayout { grid = grid { setDefaults() column({ it.title }).setHeader("Todo") column({ it.dueAt?.toString() }).setHeader("Due Date") column({ it.user?.name }).setHeader("Assignee") } } } init { configObservers() viewModel.fetchAll() } private fun configObservers() { viewModel.todos.observe { currentUi.accessSynchronously { grid.setItems(it) } } } } }
  11. class TodoViewModel : CoroutineScope { var todos: Observable<List<Todo>> = Observable()

    private val job = SupervisorJob() private val dispatcher = Dispatchers.Default override val coroutineContext: CoroutineContext get() = job + dispatcher fun fetchAll() { launch { withContext(Dispatchers.IO) { todos.value = newSuspendedTransaction(Dispatchers.IO, database) { val query = Todos.join(Users, JoinType.LEFT, Todos.userId, Users.id) .selectAll() .orderBy(Todos.completedAt, SortOrder.DESC) .orderBy(Todos.dueAt, SortOrder.ASC) .orderBy(Todos.title, SortOrder.ASC) query.map { resultRow -> Todo( id = resultRow[Todos.id].value, title = resultRow[Todos.title], dueAt = resultRow[Todos.dueAt], notes = resultRow[Todos.description], user = User( id = resultRow[Users.id]?.value, name = resultRow[Users.name] ), completedAt = resultRow[Todos.completedAt] ) } } } } } ... } }
  12. class TodoViewModel : CoroutineScope { var todos: Observable<List<Todo>> = Observable()

    private val job = SupervisorJob() private val dispatcher = Dispatchers.Default override val coroutineContext: CoroutineContext get() = job + dispatcher ... } }
  13. class TodoViewModel : CoroutineScope { var todos: Observable<List<Todo>> = Observable()

    private val job = SupervisorJob() private val dispatcher = Dispatchers.Default override val coroutineContext: CoroutineContext get() = job + dispatcher fun fetchAll() { launch { todos.value = newSuspendedTransaction(Dispatchers.IO, database) { val query = Todos.join(Users, JoinType.LEFT, Todos.userId, Users.id) .selectAll() .orderBy(Todos.completedAt, SortOrder.DESC) .orderBy(Todos.dueAt, SortOrder.ASC) .orderBy(Todos.title, SortOrder.ASC) query.map { resultRow -> Todo( id = resultRow[Todos.id].value, title = resultRow[Todos.title], dueAt = resultRow[Todos.dueAt], notes = resultRow[Todos.description], user = User( id = resultRow[Users.id]?.value, name = resultRow[Users.name] ), completedAt = resultRow[Todos.completedAt] ) } } } ... } }
  14. fun fetchAll() { launch { todos.value = newSuspendedTransaction(Dispatchers.IO, database) {

    val query = Todos.join(Users, JoinType.LEFT, Todos.userId, Users.id) .selectAll() .orderBy(Todos.completedAt, SortOrder.DESC) .orderBy(Todos.dueAt, SortOrder.ASC) .orderBy(Todos.title, SortOrder.ASC) query.map { resultRow -> Todo( id = resultRow[Todos.id].value, title = resultRow[Todos.title], dueAt = resultRow[Todos.dueAt], notes = resultRow[Todos.description], user = User( id = resultRow[Users.id]?.value, name = resultRow[Users.name] ), completedAt = resultRow[Todos.completedAt] ) } } } } }
  15. launch { todos.value = newSuspendedTransaction(Dispatchers.IO, database) { val query =

    Todos.join(Users, JoinType.LEFT, Todos.userId, Users.id) .selectAll() .orderBy(Todos.completedAt, SortOrder.DESC) .orderBy(Todos.dueAt, SortOrder.ASC) .orderBy(Todos.title, SortOrder.ASC) ... } }
  16. fun fetchAll() { launch { todos.value = newSuspendedTransaction(Dispatchers.IO, database) {

    val query = Todos.join(Users, JoinType.LEFT, Todos.userId, Users.id) .selectAll() .orderBy(Todos.completedAt, SortOrder.DESC) .orderBy(Todos.dueAt, SortOrder.ASC) .orderBy(Todos.title, SortOrder.ASC) query.map { resultRow -> Todo( id = resultRow[Todos.id].value, title = resultRow[Todos.title], dueAt = resultRow[Todos.dueAt], notes = resultRow[Todos.description], user = User( id = resultRow[Users.id]?.value, name = resultRow[Users.name] ), completedAt = resultRow[Todos.completedAt] ) } } } } }
  17. query.map { resultRow -> Todo( id = resultRow[Todos.id].value, title =

    resultRow[Todos.title], dueAt = resultRow[Todos.dueAt], notes = resultRow[Todos.description], user = User( id = resultRow[Users.id]?.value, name = resultRow[Users.name] ), completedAt = resultRow[Todos.completedAt] ) } }
  18. launch { todos.value = newSuspendedTransaction(Dispatchers.IO, database) { val query =

    Todo.all() .sortedBy { it.completedAt } .sortedBy { it.dueAt } .sortedBy { it.title } ... } }
  19. launch { todos.value = newSuspendedTransaction(Dispatchers.IO, database) { val query =

    Todos.join(Users, JoinType.LEFT, Todos.userId, Users.id) .selectAll() .orderBy(Todos.completedAt, SortOrder.DESC) .orderBy(Todos.dueAt, SortOrder.ASC) .orderBy(Todos.title, SortOrder.ASC) ... } }
  20. class TodoWithCheckbox(todo: Todo) : KComposite() { private val root =

    ui { verticalLayout { span(todo.title) } } } }
  21. class TodoWithCheckbox(todo: Todo) : KComposite() { private val root =

    ui { verticalLayout { horizontalLayout { checkBox { value = todo.completedAt != null} span(todo.title) } todo.notes?.let { span(it) } } } } }
  22. grid = grid { setDefaults() column({ it.title }).setHeader(“Todo") column({ it.dueAt?.toString()

    }).setHeader("Due Date") column({ it.user?.name }).setHeader("Assignee") } }
  23. grid = grid { setDefaults() componentColumn({ todo -> TodoWithCheckbox(todo) }).setHeader("Todo")

    column({ it.dueAt?.toString() }).setHeader("Due Date") column({ it.user?.name }).setHeader("Assignee") } }
  24. private val root = ui { verticalLayout { horizontalLayout {

    checkBox { value = todo.completedAt != null } span(todo.title) } todo.notes?.let { span(it) } } } }
  25. private val root = ui { verticalLayout { horizontalLayout {

    checkBox { value = todo.completedAt != null } span(todo.title) { addClassName("link-text") if (todo.completedAt != null) { addClassName("strikethrough") } } } todo.notes?.let { span(it) } } } }
  26. private val root = ui { verticalLayout { horizontalLayout {

    checkBox { value = todo.completedAt != null } span(todo.title) { addClassName("link-text") if (todo.completedAt != null) { addClassName("strikethrough") } } todo.notes?.let { span(it) { if (todo.completedAt != null) { addClassName("strikethrough") } } } } } }
  27. private val root = ui { verticalLayout { horizontalLayout {

    checkBox { value = todo.completedAt != null addValueChangeListener { event -> onCheckChanged(event.value) } } span(todo.title) { addClassName("link-text") if (todo.completedAt != null) { addClassName("strikethrough") } } } todo.notes?.let { span(it) { if (todo.completedAt != null) { addClassName("strikethrough") } } } } }
  28. grid = grid { setDefaults() componentColumn({ todo -> TodoWithCheckbox(todo) }).setHeader("Todo")

    column({ it.dueAt?.toString() }).setHeader("Due Date") column({ it.user?.name }).setHeader("Assignee") } }
  29. componentColumn({ todo -> TodoWithCheckbox( todo = todo, onCheckChanged = {

    isChecked -> viewModel.updateCompletion(todo, isChecked) }, onClickLink = { } ) }).setHeader("Todo") }
  30. fun updateCompletion(todo: Todo, isCompleted: Boolean) { launch { newSuspendedTransaction(Dispatchers.IO, database)

    { Todos.update({ Todos.id eq todo.id }) { it[Todos.completedAt] = if (isCompleted) LocalDate.now() else null } } fetchAll() } } }
  31. class TodoForm( private val todo: Todo? = null, onClickDelete: ((Todo)

    -> Unit)? = null, onClickSave: (Todo) -> Unit ) : KComposite() {
  32. private lateinit var dialog: Dialog private lateinit var titleField: TextField

    private lateinit var dueDateField: DatePicker private lateinit var notesField: TextArea private lateinit var userField: Select<User>
  33. private val root = ui { verticalLayout { dialog =

    openDialog { verticalLayout { formLayout { titleField = textField("Title") { isRequired = true todo?.title?.let { value = it } } dueDateField = datePicker("Due Date") { todo?.dueAt?.let { value = it } } notesField = textArea("Notes") { maxRows = 2 todo?.notes?.let { value = it } } userField = select("Assignee") { itemLabelGenerator = ItemLabelGenerator { it.name } } } ... } } } } }
  34. private val root = ui { verticalLayout { dialog =

    openDialog { verticalLayout { … } } } } }
  35. private val root = ui { verticalLayout { dialog =

    openDialog { verticalLayout { formLayout { titleField = textField("Title") { isRequired = true todo?.title?.let { value = it } } dueDateField = datePicker("Due Date") { todo?.dueAt?.let { value = it } } notesField = textArea("Notes") { maxRows = 2 todo?.notes?.let { value = it } } userField = select("Assignee") { itemLabelGenerator = ItemLabelGenerator { it.name } } } ... } } } } }
  36. formLayout { titleField = textField("Title") { isRequired = true todo?.title?.let

    { value = it } } dueDateField = datePicker("Due Date") { todo?.dueAt?.let { value = it } } notesField = textArea("Notes") { maxRows = 2 todo?.notes?.let { value = it } } userField = select("Assignee") { itemLabelGenerator = ItemLabelGenerator { it.name } } } }
  37. private val root = ui { verticalLayout { dialog =

    openDialog { verticalLayout { ... horizontalLayout { if (todo != null) { button("Delete") { onClick { onClickDelete?.invoke(todo) dialog.close() } } } button("Cancel") { onClick { dialog.close() } } button("Save") { setPrimary() onClick { if (formIsInvalid()) return@onClick val newTodo = generateTodoModel(todo) onClickSave(newTodo) dialog.close() } } } } } } } }
  38. horizontalLayout { if (todo != null) { button("Delete") { onClick

    { onClickDelete?.invoke(todo) dialog.close() } } } button("Cancel") { onClick { dialog.close() } } button("Save") { setPrimary() onClick { if (formIsInvalid()) return@onClick val newTodo = generateTodoModel() onClickSave(newTodo) dialog.close() } } } }
  39. fun fetchUsers() { viewModel.fetchUsers() } private fun configObservers() { viewModel.users.observe

    { currentUi.accessSynchronously { userField.setItems(it) todo?.user?.let { userField.value = it } } } } }
  40. fun fetchUsers() { launch { users.value = newSuspendedTransaction(Dispatchers.IO, database) {

    Users.selectAll() .orderBy(Users.name) .map { User( id = it[Users.id].value, name = it[Users.name] ?: "No Name" ) } } } }
  41. private val root = ui { verticalLayout { button("Add todo",

    icon = VaadinIcon.PLUS.create()) { onClick { TodoForm(onClickSave = { todo -> viewModel.insert(todo) }) } } ... } } }
  42. fun insert(todo: Todo) { launch { newSuspendedTransaction(Dispatchers.IO, database) { Todos.insert

    { it[Todos.title] = todo.title it[Todos.dueAt] = todo.dueAt it[Todos.description] = todo.notes it[Todos.userId] = todo.user?.id } } fetchAll() } }
  43. class TodoForm( private val todo: Todo? = null, onClickDelete: ((Todo)

    -> Unit)? = null, onClickSave: (Todo) -> Unit ) : KComposite() { ... }
  44. componentColumn({ todo -> TodoWithCheckbox( todo = todo, onCheckChanged = {

    isChecked -> viewModel.updateCompletion(todo, isChecked) }, onClickLink = { } ) }).setHeader("Todo") }
  45. componentColumn({ todo -> TodoWithCheckbox( todo = todo, onCheckChanged = {

    isChecked -> viewModel.updateCompletion(todo, isChecked) }, onClickLink = { TodoForm( todo = todo, onClickSave = { viewModel.update(it) } ) } ) }).setHeader("Todo")
  46. fun update(todo: Todo) { launch { newSuspendedTransaction(Dispatchers.IO, database) { Todos.update({

    Todos.id eq todo.id }) { it[Todos.title] = todo.title it[Todos.dueAt] = todo.dueAt it[Todos.description] = todo.notes it[Todos.userId] = todo.user?.id } } fetchAll() } }
  47. horizontalLayout { if (todo != null) { button("Delete") { onClick

    { onClickDelete?.invoke(todo) dialog.close() } } } } button("Cancel") { onClick { dialog.close() } } button("Save") { setPrimary() onClick { if (formIsInvalid()) return@onClick val newTodo = generateTodoModel() onClickSave(newTodo) dialog.close() } } }
  48. componentColumn({ todo -> TodoWithCheckbox( todo = todo, onCheckChanged = {

    isChecked -> viewModel.updateCompletion(todo, isChecked) }, onClickLink = { TodoForm( todo = todo, onClickSave = { viewModel.update(it) } ) } ) }).setHeader("Todo")
  49. onClickLink = { TodoForm( todo = todo, onClickSave = {

    viewModel.update(it) }, onClickDelete = { viewModel.delete(it) } ) }
  50. private val root = ui { verticalLayout { button("Add todo",

    icon = VaadinIcon.PLUS.create()) { onClick { TodoForm { todo -> viewModel.insert(todo) } } } ... }
  51. private val root = ui { verticalLayout { button("Add todo",

    icon = VaadinIcon.PLUS.create()) { onClick { TodoForm { todo -> viewModel.insert(todo) } } } textField { setWidthFull() isClearButtonVisible = true placeholder = "Search for todo title..." valueChangeMode = ValueChangeMode.LAZY addValueChangeListener { event -> viewModel.fetchAll(event.value) } } ... }
  52. textField { setWidthFull() isClearButtonVisible = true placeholder = "Search for

    todo title..." valueChangeMode = ValueChangeMode.LAZY addValueChangeListener { event -> viewModel.fetchAll(event.value) } }
  53. fun fetchAll() { launch { withContext(Dispatchers.IO) { todos.value = newSuspendedTransaction(Dispatchers.IO,

    database) { val query = Todos.join(Users, JoinType.LEFT, Todos.userId, Users.id) .selectAll() .orderBy(Todos.completedAt, SortOrder.DESC) .orderBy(Todos.dueAt, SortOrder.ASC) .orderBy(Todos.title, SortOrder.ASC) query.map { resultRow -> Todo( id = resultRow[Todos.id].value, title = resultRow[Todos.title], dueAt = resultRow[Todos.dueAt], notes = resultRow[Todos.description], user = User( id = resultRow[Users.id]?.value, name = resultRow[Users.name] ), completedAt = resultRow[Todos.completedAt] ) } } } } } }
  54. fun fetchAll(searchTerm: String? = null) { launch { withContext(Dispatchers.IO) {

    todos.value = newSuspendedTransaction(Dispatchers.IO, database) { val query = Todos.join(Users, JoinType.LEFT, Todos.userId, Users.id) .selectAll() .orderBy(Todos.completedAt, SortOrder.DESC) .orderBy(Todos.dueAt, SortOrder.ASC) .orderBy(Todos.title, SortOrder.ASC) query.map { resultRow -> Todo( id = resultRow[Todos.id].value, title = resultRow[Todos.title], dueAt = resultRow[Todos.dueAt], notes = resultRow[Todos.description], user = User( id = resultRow[Users.id]?.value, name = resultRow[Users.name] ), completedAt = resultRow[Todos.completedAt] ) } } } } } }
  55. launch { todos.value = newSuspendedTransaction(Dispatchers.IO, database) { val query =

    Todos.join(Users, JoinType.LEFT, Todos.userId, Users.id) .selectAll() .orderBy(Todos.completedAt, SortOrder.DESC) .orderBy(Todos.dueAt, SortOrder.ASC) .orderBy(Todos.title, SortOrder.ASC) ... } }
  56. launch { todos.value = newSuspendedTransaction(Dispatchers.IO, database) { val query =

    Todos.join(Users, JoinType.LEFT, Todos.userId, Users.id) .selectAll() .orderBy(Todos.completedAt, SortOrder.DESC) .orderBy(Todos.dueAt, SortOrder.ASC) .orderBy(Todos.title, SortOrder.ASC)
 [email protected]?.let { query.where { Todos.title.upperCase().like("%${it.uppercase()}%") } } ... } }
  57. ✅ Zero con fi guration App Server ✅ Database CRUD

    ✅ Reusable components with DSL syntax ✅ High fi delity UI with no very little CSS and zero JavaScript