The whole site is one <canvas> element. Everything else in index.html is meta tags, a noscript fallback, and a boot pulse that fades out the first time a canvas appears in the DOM.
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
ComposeViewport(document.body!!) {
App()
}
}
That's the whole entrypoint. From there it's Compose Multiplatform 1.10 against a Skiko canvas, wasmJs single target, no React, no virtual DOM, no third-party UI libs.
The split
Four modules in settings.gradle.kts:
:core # shared Kotlin (Slug, AppError, Lce)
:wire # Square Wire .proto definitions, generated for both jvm + js + wasmJs
:web # wasmJs Compose UI
:server # jsMain Cloudflare Worker (kotlinx.coroutines.promise + Workers fetch handler)
:publisher # JVM CLI that parses markdown, encodes Post protos, writes to D1, emits HTML stubs
The wire contract is the load-bearing piece. Posts are a Post proto with a repeated Block AST — paragraph, heading, code, quote, list, image. Code blocks are pre-tokenized at publish time into CodeToken { TokenKind, text } so the runtime never has to ship a syntax highlighter to the wasm bundle.
Same bytes, both directions
The publisher encodes a post once and writes the bytes into D1 as a BLOB:
client.execute(
sql = "INSERT INTO posts(...,ast,...) VALUES(?,?,?,?,?,?,1,?) ON CONFLICT(slug) DO UPDATE ...",
params = listOf(slug, title, date, summary, readingTime, post.encode(), now),
)
The Worker reads the same column and hands those bytes back over application/x-protobuf:
val astBytes = jsUint8ArrayToByteArray(row.ast)
val post = Post.ADAPTER.decode(astBytes).copy(summary = summary, view_count = ...)
protoResponse(post.encode())
The wasm client decodes them with the exact same generated Post.ADAPTER. No JSON in the loop, no schema drift between server and client because they're literally the same generated Kotlin classes from the same :wire module. The Worker is Kotlin/JS (kotlinx.coroutines.promise { ... } wrapping a fetch export), the client is Kotlin/Wasm — different backends, one source of truth.
The SEO escape hatch
Wasm-only sites are invisible to anything that doesn't run JS. The publisher solves this at write time, not request time. After encoding the proto, it calls StubRenderer.emitPost(post, distDir) and walks the same AST out to plain HTML at dist/blog/<slug>/index.html:
b.heading != null -> appendLine("<h${h.level}>${inlineHtml(h.inlines)}</h${h.level}>")
b.paragraph != null -> appendLine("<p>${inlineHtml(b.paragraph!!.inlines)}</p>")
b.code != null -> appendLine("<pre><code class=\"lang-...\">${esc(src)}</code></pre>")
Each stub also embeds <script type="module" src="/evanSite.js"></script> so a real browser hitting /blog/<slug> boots Compose over the top of the static markup. Crawlers get the words, OG previews get a title and description, JSON-LD BlogPosting gets indexed. The Compose UI never has to know any of this happened.
Sharp edges
A few things that bit:
-
wasmJsis a real Kotlin target now, but JS interop is still mostly unsafe casts. The fetch shim inApiClient.ktis six lines ofjs("...")and oneunsafeCast<FetchResponseShim>()— there is no idiomatic alternative yet. -
Cloudflare Pages doesn't always serve
.wasmwithapplication/wasmon first deploy. Thedeploy/_headersfile fixes it; you have to remember to copy it into the dist dir before publishing. -
The publisher CLI invokes
wrangleras a subprocess for D1 writes (D1Client(workingDir = serverDir)), which means the publisher is a JVM tool that shells out to a Node tool that talks to a Workers runtime. Three runtimes for oneINSERT. It works.
The whole publish loop is one command:
./gradlew :publisher:run --args="publish publisher/content/posts/2026-05-08-compose-on-wasm-architecture.md"
That's the post you're reading.