Skip to content

Core concepts

Host-owned routing

Page ids live on the server. Packs implement ids, never paths.

URLs are a host concern. A pack is addressed by page id (harbor.home), not by / or /u/{id}. The registry is the router.

Pages DSL
fun Application.harbor(bundle: FrontendBundle) {
  keel {
      this.bundle = bundle
      title = "Harbor"
      notFoundPageId = "harbor.notFound"
      pages {
          page<HomePage>("harbor.home", "/") {
              head("Harbor", description = "An in-memory message board.")
              HomePage(viewer = Board.viewer(call)?.toRef(), feed = Board.feed())
          }
          page<UserPage>("harbor.user", "/u/{id}") {
              val user = Board.user(params.getValue("id"))
                  ?: throw PageMissingException(path)
              head("${user.displayName} — Harbor")
              UserPage(user = user.toRef(), messages = Board.messagesFor(user.id))
          }
      }
  }
}

respondPage is the primitive. The pages DSL is sugar: register path + loader, then on match call respondPage. Any Ktor route can return a Keel page.

Custom tree
routing {
  route("/shop") {
      keel(shopBundle) {
          get("/{id}") {
              val item = load(call.parameters.getOrFail("id"))
              call.respondPage("shop.item", ShopItemPage(item), params = mapOf("id" to item.id))
          }
      }
  }
}

route.keel(pack) scopes the route subtree to a pack and makes it the default so respondPage(pageId, data) can omit the bundle argument. It does not steal unmatched paths.

First load is a document (HTML shell + seed). Client navigation is a visit: the same page URL with X-Keel-Visit: true, JSON back. Path grammar, status codes, and headers live on the wire protocol.

The pack never invents routes. It visits host URLs:

Link and router.visit
<script lang="ts">
import { Link, page, router } from "@kolektiv/keel-svelte"
import type { HomePage } from "@app/page-types"

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

<nav>
<Link href="/" prefetch="hover">Harbor</Link>
{#each ctx.data.feed as item (item.id)}
  <Link href={`/u/${item.userId}`}>{item.displayName}</Link>
{/each}
</nav>

<button type="button" onclick={() => router.visit("/settings", { preserveScroll: true })}>
Settings
</button>
<script>
import { Link, page, router } from "@kolektiv/keel-svelte"

const ctx = page()
</script>

<nav>
<Link href="/" prefetch="hover">Harbor</Link>
{#each ctx.data.feed as item (item.id)}
  <Link href={`/u/${item.userId}`}>{item.displayName}</Link>
{/each}
</nav>

<button type="button" onclick={() => router.visit("/settings", { preserveScroll: true })}>
Settings
</button>
import { Link, router, usePage } from "@kolektiv/keel-react"
import type { HomePage } from "@app/page-types"

export default function Nav() {
const seed = usePage<HomePage>()
return (
  <>
    <nav>
      <Link href="/" prefetch="hover">Harbor</Link>
      {seed.data.feed.map((item) => (
        <Link key={item.id} href={`/u/${item.userId}`}>{item.displayName}</Link>
      ))}
    </nav>
    <button type="button" onClick={() => router.visit("/settings", { preserveScroll: true })}>
      Settings
    </button>
  </>
)
}
import { Link, router, usePage } from "@kolektiv/keel-react"

export default function Nav() {
const seed = usePage()
return (
  <>
    <nav>
      <Link href="/" prefetch="hover">Harbor</Link>
      {seed.data.feed.map((item) => (
        <Link key={item.id} href={`/u/${item.userId}`}>{item.displayName}</Link>
      ))}
    </nav>
    <button type="button" onClick={() => router.visit("/settings", { preserveScroll: true })}>
      Settings
    </button>
  </>
)
}
<script setup lang="ts">
import { Link, router, usePage } from "@kolektiv/keel-vue"
import type { HomePage } from "@app/page-types"

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

<template>
<nav>
  <Link href="/" prefetch="hover">Harbor</Link>
  <Link v-for="item in seed.data.feed" :key="item.id" :href="`/u/${item.userId}`">
    {{ item.displayName }}
  </Link>
</nav>
<button type="button" @click="router.visit('/settings', { preserveScroll: true })">
  Settings
</button>
</template>
<script setup>
import { Link, router, usePage } from "@kolektiv/keel-vue"

const seed = usePage()
</script>

<template>
<nav>
  <Link href="/" prefetch="hover">Harbor</Link>
  <Link v-for="item in seed.data.feed" :key="item.id" :href="`/u/${item.userId}`">
    {{ item.displayName }}
  </Link>
</nav>
<button type="button" @click="router.visit('/settings', { preserveScroll: true })">
  Settings
</button>
</template>
import { Link, router, usePage } from "@kolektiv/keel-solid"
import type { HomePage } from "@app/page-types"

export default function Nav() {
const seed = usePage<HomePage>()
return (
  <>
    <nav>
      <Link href="/" prefetch="hover">Harbor</Link>
      {seed().data.feed.map((item) => (
        <Link href={`/u/${item.userId}`}>{item.displayName}</Link>
      ))}
    </nav>
    <button type="button" onClick={() => router.visit("/settings", { preserveScroll: true })}>
      Settings
    </button>
  </>
)
}
import { Link, router, usePage } from "@kolektiv/keel-solid"

export default function Nav() {
const seed = usePage()
return (
  <>
    <nav>
      <Link href="/" prefetch="hover">Harbor</Link>
      {seed().data.feed.map((item) => (
        <Link href={`/u/${item.userId}`}>{item.displayName}</Link>
      ))}
    </nav>
    <button type="button" onClick={() => router.visit("/settings", { preserveScroll: true })}>
      Settings
    </button>
  </>
)
}
import { Link, router, usePage } from "@kolektiv/keel-preact"
import type { HomePage } from "@app/page-types"

export default function Nav() {
const seed = usePage<HomePage>()
return (
  <>
    <nav>
      <Link href="/" prefetch="hover">Harbor</Link>
      {seed.data.feed.map((item) => (
        <Link key={item.id} href={`/u/${item.userId}`}>{item.displayName}</Link>
      ))}
    </nav>
    <button type="button" onClick={() => router.visit("/settings", { preserveScroll: true })}>
      Settings
    </button>
  </>
)
}
import { Link, router, usePage } from "@kolektiv/keel-preact"

export default function Nav() {
const seed = usePage()
return (
  <>
    <nav>
      <Link href="/" prefetch="hover">Harbor</Link>
      {seed.data.feed.map((item) => (
        <Link key={item.id} href={`/u/${item.userId}`}>{item.displayName}</Link>
      ))}
    </nav>
    <button type="button" onClick={() => router.visit("/settings", { preserveScroll: true })}>
      Settings
    </button>
  </>
)
}
import { html } from "lit"
import { KeelElement, router } from "@kolektiv/keel-lit"
import type { HomePage } from "@app/page-types"

export default class Nav extends KeelElement<HomePage> {
render() {
  return html`
    <nav>
      <keel-link href="/" prefetch="hover">Harbor</keel-link>
      ${this.page.data.feed.map(
        (item) => html`<keel-link href="/u/${item.userId}">${item.displayName}</keel-link>`,
      )}
    </nav>
    <button type="button" @click=${() => router.visit("/settings", { preserveScroll: true })}>
      Settings
    </button>
  `
}
}
import { html } from "lit"
import { KeelElement, router } from "@kolektiv/keel-lit"

export default class Nav extends KeelElement {
render() {
  return html`
    <nav>
      <keel-link href="/" prefetch="hover">Harbor</keel-link>
      ${this.page.data.feed.map(
        (item) => html`<keel-link href="/u/${item.userId}">${item.displayName}</keel-link>`,
      )}
    </nav>
    <button type="button" @click=${() => router.visit("/settings", { preserveScroll: true })}>
      Settings
    </button>
  `
}
}
import { Component } from "@angular/core"
import { KeelLink, injectKeelPage, router } from "@kolektiv/keel-angular"
import type { HomePage } from "@app/page-types"

@Component({
selector: "app-nav",
standalone: true,
imports: [KeelLink],
template: `
  <nav>
    <a [keelLink]="'/'" prefetch="hover">Harbor</a>
    @for (item of page().data.feed; track item.id) {
      <a [keelLink]="'/u/' + item.userId">{{ item.displayName }}</a>
    }
  </nav>
  <button type="button" (click)="navigate.visit('/settings', { preserveScroll: true })">
    Settings
  </button>
`,
})
export default class Nav {
readonly page = injectKeelPage<HomePage>()
readonly navigate = router
}
import { Component } from "@angular/core"
import { KeelLink, injectKeelPage, router } from "@kolektiv/keel-angular"

@Component({
selector: "app-nav",
standalone: true,
imports: [KeelLink],
template: `
  <nav>
    <a [keelLink]="'/'" prefetch="hover">Harbor</a>
    @for (item of page().data.feed; track item.id) {
      <a [keelLink]="'/u/' + item.userId">{{ item.displayName }}</a>
    }
  </nav>
  <button type="button" (click)="navigate.visit('/settings', { preserveScroll: true })">
    Settings
  </button>
`,
})
export default class Nav {
readonly page = injectKeelPage()
readonly navigate = router
}

replace swaps the current history entry (login, redirects). preserveState on the same page id calls update() instead of remounting. Successful visits pushState the seed; back/forward re-applies it without a round-trip unless the entry module changed.