URLs are a host concern. A pack is addressed by page id (harbor.home),
not by / or /u/{id}. The registry is the router.
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.
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:
<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.