diff --git a/_posts/tutorials/community/2026-03-26-javalin-bpdbi-kotlin.md b/_posts/tutorials/community/2026-03-26-javalin-bpdbi-kotlin.md
new file mode 100644
index 0000000..973d6d3
--- /dev/null
+++ b/_posts/tutorials/community/2026-03-26-javalin-bpdbi-kotlin.md
@@ -0,0 +1,323 @@
+---
+layout: tutorial
+official: false
+title: "Javalin with Bpdbi: A Pipelining-first Postgres Client in Kotlin"
+permalink: /tutorials/javalin-bpdbi-kotlin
+summarytitle: Using Javalin with Bpdbi (Kotlin)
+summary: Build a REST API with Javalin and Bpdbi — a lightweight, pipelining-first Postgres driver that bypasses JDBC for better performance and a simpler stack.
+date: 2026-03-26
+author: Cies Breijs
+language: ["kotlin", "gradle", "postgres", "sql"]
+rightmenu: true
+github: https://github.com/bpdbi/bpdbi
+---
+
+## Why Javalin + Bpdbi?
+
+Javalin and Bpdbi share the same philosophy: **do one thing well, stay lightweight, and get out of your way.**
+
+Javalin gives you a simple HTTP layer without the ceremony of a full framework.
+Bpdbi gives you a simple database layer without the ceremony of JDBC + a connection pool library + a query abstraction library (like Jdbi, Spring JDBC Template, etc.).
+
+Together they make for an exceptionally lightweight stack:
+
+- **No JDBC** — Bpdbi speaks the Postgres wire protocol directly, which unlocks pipelining and binary-for-all encoding
+- **No Netty** — plain `java.net.Socket`, no event loop, no reactive machinery
+- **No reflection** — the `bpdbi-kotlin` module uses `kotlinx.serialization` for row mapping, which does not use the reflection API
+- **Simplicity of code** — since Bpdbi used the good old blocking paradigm the code is very readable
+
+The total dependency footprint for the database side is under 200KB — compare that to JDBC driver + HikariCP + Jdbi (several MB) or Hibernate (~15MB).
+
+## What is pipelining?
+
+Pipelining sends multiple SQL statements to the database in a single network write and reads all responses back at once. This reduces the number of round-trips, which is especially valuable when:
+
+- You need to run setup statements before your actual query (e.g. `BEGIN`, `SET`, RLS configuration)
+- You need results from multiple independent queries in a single request
+- You're inserting or updating multiple rows
+
+For example, a typical "start transaction + query" that takes 2 round-trips with JDBC can be done in 1 round-trip with Bpdbi:
+
+```kotlin
+conn.enqueue("BEGIN")
+conn.enqueue("SET LOCAL statement_timeout TO '5s'")
+val result = conn.sql("SELECT * FROM users WHERE id = :id")
+ .bind("id", userId)
+ .query()
+// All three statements sent in a single network write
+```
+
+In benchmarks with 1ms simulated network latency, pipelining gives a **2-17x speedup** depending on the scenario.
+
+## Project setup
+
+### Gradle (build.gradle.kts)
+
+```kotlin
+plugins {
+ kotlin("jvm") version "2.1.20"
+ kotlin("plugin.serialization") version "2.1.20"
+ application
+}
+
+group = "com.example"
+
+repositories {
+ mavenCentral()
+}
+
+application {
+ mainClass.set("com.example.AppKt")
+}
+
+val javalinVersion = "6.6.0"
+val bpdbiVersion = "0.1.0"
+
+dependencies {
+ implementation("io.javalin:javalin-bundle:$javalinVersion")
+ implementation(platform("io.github.bpdbi:bpdbi-bom:$bpdbiVersion"))
+ implementation("io.github.bpdbi:bpdbi-pg-client")
+ implementation("io.github.bpdbi:bpdbi-pool")
+ implementation("io.github.bpdbi:bpdbi-kotlin")
+}
+```
+
+The `bpdbi-bom` aligns all module versions. The three modules we use:
+
+- **bpdbi-pg-client** — the Postgres driver (speaks wire protocol directly)
+- **bpdbi-pool** — a lightweight connection pool
+- **bpdbi-kotlin** — Kotlin extensions and `kotlinx.serialization`-based row mapping
+
+### Docker Compose with Postgres
+
+```yaml
+services:
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_USER: demo
+ POSTGRES_PASSWORD: demo
+ POSTGRES_DB: demo
+ ports:
+ - "5432:5432"
+```
+
+Start it with `docker compose up -d`.
+
+## Data model
+
+Define a simple `tasks` table. We'll create it on app startup:
+
+```kotlin
+private fun initSchema(pool: ConnectionPool) {
+ pool.withConnection { conn ->
+ conn.query("""
+ CREATE TABLE IF NOT EXISTS tasks (
+ id SERIAL PRIMARY KEY,
+ title TEXT NOT NULL,
+ done BOOLEAN NOT NULL DEFAULT false
+ )
+ """)
+ }
+}
+```
+
+And a Kotlin data class to map rows into, using `kotlinx.serialization`:
+
+```kotlin
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class Task(val id: Int, val title: String, val done: Boolean)
+```
+
+That's it — no reflection configuration, no code generation, no runtime dependencies. The `kotlinx.serialization` compiler plugin handles everything at compile time.
+
+## Connection pool
+
+Set up the pool once at application startup:
+
+```kotlin
+import io.github.bpdbi.pg.PgConnection
+import io.github.bpdbi.pool.ConnectionPool
+import io.github.bpdbi.pool.PoolConfig
+
+fun createPool(): ConnectionPool {
+ return ConnectionPool(
+ { PgConnection.connect("localhost", 5432, "demo", "demo", "demo") },
+ PoolConfig()
+ .maxSize(10)
+ .connectionTimeoutMillis(5000)
+ )
+}
+```
+
+The pool is virtual-thread friendly: blocking on `acquire()` is cheap when Javalin dispatches requests to virtual threads.
+
+## Handlers
+
+Now we wire up the HTTP handlers. Bpdbi's `sql()` builder with named parameters (`:name`) and `.bind()` makes the code very readable:
+
+```kotlin
+import io.github.bpdbi.kotlin.deserializeFirst
+import io.github.bpdbi.kotlin.deserializeFirstOrNull
+import io.github.bpdbi.kotlin.deserializeToList
+import io.javalin.http.Context
+import io.javalin.http.HttpStatus
+
+class TaskController(private val pool: ConnectionPool) {
+
+ fun getAll(ctx: Context) {
+ val tasks = pool.withConnection { conn ->
+ conn.sql("SELECT id, title, done FROM tasks ORDER BY id")
+ .query()
+ .deserializeToList()
+ }
+ ctx.json(tasks)
+ }
+
+ fun getOne(ctx: Context) {
+ val id = ctx.pathParam("id").toInt()
+ val task = pool.withConnection { conn ->
+ conn.sql("SELECT id, title, done FROM tasks WHERE id = :id")
+ .bind("id", id)
+ .query()
+ .deserializeFirstOrNull()
+ }
+ if (task != null) ctx.json(task)
+ else ctx.status(HttpStatus.NOT_FOUND)
+ }
+
+ fun create(ctx: Context) {
+ val body = ctx.bodyAsClass()
+ val task = pool.withConnection { conn ->
+ conn.sql("INSERT INTO tasks (title) VALUES (:title) RETURNING id, title, done")
+ .bind("title", body.title)
+ .query()
+ .deserializeFirst()
+ }
+ ctx.json(task).status(HttpStatus.CREATED)
+ }
+
+ fun update(ctx: Context) {
+ val id = ctx.pathParam("id").toInt()
+ val body = ctx.bodyAsClass()
+ val task = pool.withConnection { conn ->
+ conn.sql("UPDATE tasks SET title = :title, done = :done WHERE id = :id RETURNING id, title, done")
+ .bind("title", body.title)
+ .bind("done", body.done)
+ .bind("id", id)
+ .query()
+ .deserializeFirstOrNull()
+ }
+ if (task != null) ctx.json(task)
+ else ctx.status(HttpStatus.NOT_FOUND)
+ }
+
+ fun delete(ctx: Context) {
+ val id = ctx.pathParam("id").toInt()
+ pool.withConnection { conn ->
+ conn.sql("DELETE FROM tasks WHERE id = :id")
+ .bind("id", id)
+ .query()
+ }
+ ctx.status(HttpStatus.NO_CONTENT)
+ }
+}
+
+@Serializable
+data class CreateTask(val title: String)
+
+@Serializable
+data class UpdateTask(val title: String, val done: Boolean)
+```
+
+Notice how there's no `ResultSet` iteration, no `try/catch (SQLException)`, no `RowMapper` boilerplate.
+Named parameters (`:title`, `:id`) are more readable than positional `$1, $2` placeholders, and the `deserializeToList()` / `deserializeFirst()` extensions handle mapping using the `@Serializable` annotation — all at compile time.
+
+Of course you can move the db queries to a separate namespace, in a Model View Controller kind of fashion.
+But that's beyond the scope of this tutorial.
+
+## Pipelining in action
+
+Here's where Bpdbi really shines. Suppose you need to fetch a task and its related comments in a single request:
+
+```kotlin
+fun getTaskWithComments(ctx: Context) {
+ val id = ctx.pathParam("id").toInt()
+ pool.withConnection { conn ->
+ val taskQx = conn.sql("SELECT id, title, done FROM tasks WHERE id = :id")
+ .bind("id", id).enqueue()
+ val commentsQx = conn.sql("SELECT id, body, created_at FROM comments WHERE task_id = :taskId")
+ .bind("taskId", id).enqueue()
+ val results = conn.flush()
+
+ val task = results[taskQx].deserializeFirstOrNull()
+ val comments = results[commentsQx].deserializeToList()
+
+ if (task != null) {
+ ctx.json(mapOf("task" to task, "comments" to comments))
+ } else {
+ ctx.status(HttpStatus.NOT_FOUND)
+ }
+ }
+}
+```
+
+Two queries, **one network round-trip**. With JDBC, this would always be two round-trips — there's no way around it.
+
+## Putting it all together
+
+```kotlin
+import io.javalin.Javalin
+import io.javalin.apibuilder.ApiBuilder.*
+
+fun main() {
+ val pool = createPool()
+ initSchema(pool)
+
+ val tasks = TaskController(pool)
+
+ val app = Javalin.create { config ->
+ config.router.apiBuilder {
+ path("/tasks") {
+ get(tasks::getAll)
+ post(tasks::create)
+ path("/{id}") {
+ get(tasks::getOne)
+ put(tasks::update)
+ delete(tasks::delete)
+ }
+ }
+ }
+ }.start(7070)
+
+ Runtime.getRuntime().addShutdownHook(Thread {
+ app.stop()
+ pool.close()
+ })
+}
+```
+
+Run it with `./gradlew run`, then test with curl:
+
+```bash
+# Create a task
+curl -X POST http://localhost:7070/tasks \
+ -H "Content-Type: application/json" \
+ -d '{"title": "Write tutorial"}'
+
+# List all tasks
+curl http://localhost:7070/tasks
+
+# Mark as done
+curl -X PUT http://localhost:7070/tasks/1 \
+ -H "Content-Type: application/json" \
+ -d '{"title": "Write tutorial", "done": true}'
+```
+
+## Conclusion
+
+Javalin and Bpdbi make for a remarkably lean stack: a simple HTTP server talking directly to Postgres over the binary wire protocol, with compile-time row mapping and first-class pipelining. No JDBC, no Netty, no reflection, no heavyweight frameworks.
+
+The full Bpdbi documentation and source code can be found on [GitHub](https://github.com/bpdbi/bpdbi).