Skip to content

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.

http://localhost:8080/
Harbor
Home

Typed seed

Hello

A greeting from Ktor. This pack is just a page — routes and data stay on the host.

@Serializable
data class HomePage(val greeting: String)

pages {
  page<HomePage>("home", "/") {
    HomePage("Hello")
  }
}
<script lang="ts">
  import { Link, page } from "@kolektiv/keel-svelte"
  import type { HomePage } from "@app/page-types"

  const ctx = page<HomePage>()
</script>

<nav>
  <Link href="/" prefetch="hover">Harbor</Link>
</nav>
<main>
  <p>Typed seed</p>
  <h1>{ctx.data.greeting}</h1>
  <p>A greeting from Ktor. This pack is just a page.</p>
  <Link href="/notes">Open notes</Link>
</main>
<script>
  import { Link, page } from "@kolektiv/keel-svelte"

  const ctx = page()
</script>

<nav>
  <Link href="/" prefetch="hover">Harbor</Link>
</nav>
<main>
  <p>Typed seed</p>
  <h1>{ctx.data.greeting}</h1>
  <p>A greeting from Ktor. This pack is just a page.</p>
  <Link href="/notes">Open notes</Link>
</main>
@Serializable
data class HomePage(val greeting: String)

pages {
  page<HomePage>("home", "/") {
    HomePage("Hello")
  }
}
import { Head, Link, usePage } from "@kolektiv/keel-react"
import type { HomePage } from "@app/page-types"

export default function Home() {
  const seed = usePage<HomePage>()
  return (
    <>
      <Head />
      <h1>{seed.data.greeting}</h1>
      <Link href="/notes">Open notes</Link>
    </>
  )
}
@Serializable
data class HomePage(val greeting: String)

pages {
  page<HomePage>("home", "/") {
    HomePage("Hello")
  }
}
<script setup lang="ts">
import { Head, Link, usePage } from "@kolektiv/keel-vue"
import type { HomePage } from "@app/page-types"

const seed = usePage<HomePage>()
</script>

<template>
  <Head />
  <h1>{{ seed.data.greeting }}</h1>
  <Link href="/notes">Open notes</Link>
</template>
@Serializable
data class HomePage(val greeting: String)

pages {
  page<HomePage>("home", "/") {
    HomePage("Hello")
  }
}
import { Head, Link, usePage } from "@kolektiv/keel-solid"
import type { HomePage } from "@app/page-types"

export default function Home() {
  const seed = usePage<HomePage>()
  return (
    <>
      <Head />
      <h1>{seed().data.greeting}</h1>
      <Link href="/notes">Open notes</Link>
    </>
  )
}
@Serializable
data class HomePage(val greeting: String)

pages {
  page<HomePage>("home", "/") {
    HomePage("Hello")
  }
}
import { Head, Link, usePage } from "@kolektiv/keel-preact"
import type { HomePage } from "@app/page-types"

export default function Home() {
  const seed = usePage<HomePage>()
  return (
    <>
      <Head />
      <h1>{seed.data.greeting}</h1>
      <Link href="/notes">Open notes</Link>
    </>
  )
}
@Serializable
data class HomePage(val greeting: String)

pages {
  page<HomePage>("home", "/") {
    HomePage("Hello")
  }
}
import { html } from "lit"
import { KeelElement } from "@kolektiv/keel-lit"
import type { HomePage } from "@app/page-types"

export default class Home extends KeelElement<HomePage> {
  render() {
    return html`
      <keel-head></keel-head>
      <h1>${this.page.data.greeting}</h1>
      <keel-link href="/notes">Open notes</keel-link>
    `
  }
}
@Serializable
data class HomePage(val greeting: String)

pages {
  page<HomePage>("home", "/") {
    HomePage("Hello")
  }
}
import { Component } from "@angular/core"
import { KeelHead, KeelLink, injectKeelPage } from "@kolektiv/keel-angular"
import type { HomePage } from "@app/page-types"

@Component({
  selector: "app-home",
  standalone: true,
  imports: [KeelHead, KeelLink],
  template: `
    <keel-head />
    <h1>{{ page().data.greeting }}</h1>
    <keel-link href="/notes">Open notes</keel-link>
  `,
})
export default class Home {
  readonly page = injectKeelPage<HomePage>()
}

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.
PageRegistry.kt
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.

  • 01keel-scaffold <origin> ./pack: schema in, Vite project + empty pages out.
  • 02pnpm builddist/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.

FrontendBundle.kt
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 with route.keel(pack).
  • 03Keel loads the page module and CSS from that pack only.
Render.kt
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.

HeaderPurpose
X-Keel-VisitJSON seed instead of the HTML shell
X-Keel-OnlyPartial reload: keep these data keys
X-Keel-ThemeServing pack id (response)
X-Keel-VersionServing pack version (response)
X-Keel-BuildServing 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/schema keep 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.