ktsx: Bringing JSX-Inspired Syntax to Kotlin Multiplatform UI

How I built a Gradle plugin and KSP processor that compiles a JSX-like .ktsx syntax into idiomatic Kotlin DSL code — and the lessons from writing a compile-time code generator.

I’ve been writing Android UI in Jetpack Compose for a couple of years now. I like it, but one thing about it has always bugged me compared with React: the mental model for conditional rendering.

In React:

{
  isLoggedIn && <UserProfile />;
}
{
  items.map((item) => <Item key={item.id} {...item} />);
}

In Compose:

if (isLoggedIn) {
    UserProfile()
}
items.forEach { item ->
    Item(item)
}

Kotlin’s version isn’t wrong; it’s perfectly fine Kotlin. But the JSX version maps more naturally to how I think about UI as a function of data, and I wanted something in between.

ktsx is my experiment at bringing that syntax to Kotlin Multiplatform.

What it does

ktsx introduces .ktsx files that compile to .kt at build time via a Gradle plugin and a KSP (Kotlin Symbol Processing) processor. The syntax looks like this:

<Column modifier={Modifier.fillMaxSize().padding(16.dp)}>
  <if condition={isLoggedIn}>
    <UserProfile name={user.name} />
  </if>
  <for each={items} as="item">
    <ItemCard title={item.title} />
  </for>
  <show when={isLoading}>
    <CircularProgressIndicator />
  </show>
</Column>

That compiles to idiomatic Compose Kotlin at build time, with no reflection and no runtime overhead.

Why I built it

Partly as a learning project, since I wanted to understand how Gradle plugins and KSP processors work, and partly because I actually wanted to use this syntax. The directives (if, for, show) read clearly in code review and are harder to misplace by accident than nested Kotlin if blocks inside composables.

The hard parts

Writing the Gradle plugin

I’d never written a Gradle plugin before. The API surface is enormous and the documentation is scattered between Gradle’s own docs, the Kotlin Gradle plugin docs, and a handful of blog posts. Getting the plugin to hook into the Kotlin compilation pipeline at the right phase, after source sets are resolved but before KSP runs, took a lot of trial and error.

What made it work was using KotlinCompilation.compileTaskProvider.configure to inject a pre-compile transformation task that rewrites .ktsx files to .kt before the Kotlin compiler even sees them.

The parser

I wrote a hand-rolled recursive descent parser for the ktsx syntax. I briefly considered using ANTLR, but it felt like overkill for what’s essentially a simple XML-like syntax with Kotlin expressions in {} attribute positions.

The tricky part is the expression parser inside attributes. {user.name} is straightforward, but {items.filter { it.active }.take(5)} has nested braces that naive bracket-counting would mishandle. I ended up tracking brace depth instead of treating the content as a raw string.

The ComponentRegistry

One design decision I’m still unsure about is the ComponentRegistry, which lets you register custom composables so ktsx knows how to output them. It works, but it adds configuration overhead that feels un-Kotlin-like. I’m exploring whether KSP annotations on the composable itself could eliminate the registry.

Why not just use React Native?

It’s the obvious question. JSX syntax, a component model, mobile UI: that’s React Native’s whole pitch. So why build ktsx instead of switching?

You stay in Kotlin

React Native is JavaScript (or TypeScript) running on a JS engine, calling native modules through a bridge. ktsx is Kotlin, compiled to native bytecode and running on the JVM or Kotlin/Native depending on your target. There’s no bridge, no JS thread, and no serialisation overhead between your UI logic and the platform.

If your codebase is already Kotlin, including your business logic, data layer, and networking, ktsx lets your UI live in the same language and the same module, sharing types, models, and test infrastructure. With React Native you split your codebase into two languages whether you want to or not.

You keep Jetpack Compose

ktsx compiles down to Compose. That means you get everything Compose gives you for free: the animation APIs, the state primitives, remember, LaunchedEffect, and the full layout system. You aren’t working around a framework; you’re adding syntax sugar on top of one that already works well.

React Native’s rendering has improved a lot with the new architecture (JSI, Fabric), but it still doesn’t map one to one to native UI components. Compose does. A LazyColumn in Compose is a RecyclerView under the hood, while a React Native FlatList goes through an abstraction layer before it reaches the platform. For most apps that doesn’t matter; for scroll-heavy or animation-heavy UIs it can.

No context switching

This is the practical one. If your team writes Kotlin all day and you reach for React Native, you’re asking everyone to switch into a different language, a different build system (Metro bundler alongside Gradle), and a different debugging workflow. ktsx is Gradle-native and fits the build you already have.

What React Native is better at

If your team is mostly TypeScript engineers, React Native is the right call. The ecosystem is enormous: Expo, React Navigation, and a decade of battle-tested libraries. Code sharing with a web frontend is real and works well. And if you want JSX syntax in a mobile app and don’t already have a Kotlin codebase, there’s no reason to reach for ktsx.

ktsx is for Kotlin teams who want the clarity of JSX-style conditional and list rendering without leaving the platform they’re already on. It’s not a React Native replacement; it answers a different question.

What’s next

ktsx is very new; the repository is only a few days old as I write this. The core compilation pipeline works and the directive system (if, else, for, show) is functional. Next up are better error messages, since a syntax error in a .ktsx file currently produces a confusing Kotlin compiler error, and then a VS Code extension for syntax highlighting.

If you work in Kotlin Multiplatform and the JSX-style syntax appeals to you, I’d love to hear what you think about the design. The repo is on GitHub.