Keel
Host-owned routing for Kotlin MPAs. Declare page ids and their data on the server, then pass the pack you want at render time. One frontend is an app; many packs are themes against that same typed contract.
Typed seed
Hello
A greeting from Ktor. This pack is just a page — routes and data stay on the host.
Page contract
Ids and types live on the host.
URLs and payload types are a Kotlin concern. A pack implementsblog.post, never the path pattern. Write one pack for your app, or install several against those ids. The call site passes the one that renders.
- 01Register
page<T>(id, path): id, serializer, route. - 02Gradle typegen (or a live
GET /__keel/schema) emits the TS/JSON contract packs compile against. - 03Pass the pack that implements each id at the call site. Keel renders from that pack only. It does not shop among installed packs.
pages {
page<BlogPostPage>("blog.post", "/p/{slug}") { call ->
BlogPostPage(posts.bySlug(call.parameters.getOrFail("slug")))
}
}Frontend packs
A pack is a .feb, not a second app.
Packs ship as a zip of manifest.json plus modules keyed by page id. Vite + keelPack builds them; the host loads a FrontendBundle from a jar resource, a file, or an exploded directory in dev. Scaffold from a running host and you get blank pages for every id in the schema.
- 01
keel-scaffold <origin> ./pack: schema in, Vite project + empty pages out. - 02
pnpm build→dist/app.feb. - 03Pass the opened pack into render. Same contract, new UI.
Seven adapters ship today: Svelte 5, React 18/19, Vue 3, Solid 1.9, Preact 10, Lit 3, and Angular 19, all against the same contract. A pack names a frameworkand plugs in a RouterAdapter; the host does not care which one it renders. SeeFramework adapters.
val prod = FrontendBundle.fromFile(Path.of("pack/dist/app.feb"))
val dev = FrontendBundle.fromDirectory(Path.of("pack/dist"))
val jar = FrontendBundle.fromResource("keel/app.feb")Packs at render time
Pass the pack you want. Keel does not pick themes.
Open a FrontendBundle once (a jar resource, a file, or an exploded directory in dev) and pass it into page render with the seed. The call site owns pack choice: a tenant route, an A/B branch, or an admin override is just the pack you pass. Keel never shops among installed packs.
- 01Open or reuse the pack once. The bundle stays open for the process lifetime.
- 02Pass it into the page render with the seed:
respondPage(pack, id, data), or scope a subtree withroute.keel(pack). - 03Keel loads the page module and CSS from that pack only.
val pack = FrontendBundle.fromFile(Path.of("pack/dist/app.feb"))
routing {
keel(pack) {
get("/") {
call.respondPage(pack, "harbor.home", HomePage(greeting = "hello"))
}
}
}Visits
Navigation is a seed, not HTML.
First load is a document: shell, #__keel_seed, module URL. After that, X-Keel-Visit returns the same JSON. Partial reloads keep the module. A 422 is still a seed, with errors.
| Header | Purpose |
|---|---|
| X-Keel-Visit | JSON seed instead of the HTML shell |
| X-Keel-Only | Partial reload: keep these data keys |
| X-Keel-Theme | Serving pack id (response) |
| X-Keel-Version | Serving pack version (response) |
| X-Keel-Build | Serving pack content hash (response) |
Why Keel
The contract stays on the server.
Kotlin is the source of truth
kotlinx.serialization types + page ids. Packs don’t invent routes.
Typed end to end
Typegen and
/__keel/schemakeep host and pack honest at build time.Swappable UI
Packs against one contract. Pass the one you want at render.
MPA bones, visit speed
Real URLs and document SEO; client navigations fetch seeds.
Scaffold, don’t guess
Blank pages for every registered id, ready for Vite.
Start with a page id.
Install the core artifact, register a page, scaffold or build a pack, then pass it at render time. One frontend is an app; more packs are themes against the same contract.