Skip to content

Core concepts

Routing primitives

Framework-agnostic router plus thin per-framework adapters.

The router lives in @kolektiv/keel. @kolektiv/keel-svelte wraps it, and React, Vue, Solid, Preact, Lit, and Angular adapters expose the same surface through their own idioms. The table lists the core exports; the examples below show each adapter’s spelling. See Framework adapters for the full matrix.

Export Kind Notes
router core visit, get/post/put/patch/delete, reload, prefetch, cancel, on
action core POST /__keel/action/{id} — typed write, not a page
Link component Renders <a> for GET. Non-GET is a button.
keel action use:keel on an existing anchor
page() function Reactive seed
Form component Progressive enhancement over router.visit
useForm function Field state, errors, processing, progress
useAction function TanStack mutation: action, then reload the seed
Head component Reads page().head; props override
Link, page, visit
<script lang="ts">
import { Link, page, router } from "@kolektiv/keel-svelte"
import type { HomePage } from "@app/page-types"

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

<Link href="/u/ada" prefetch="hover">{ctx.data.viewer?.displayName}</Link>
<a href="/u/ada" use:keel={{ prefetch: "hover" }}>Ada</a>
<button type="button" onclick={() => router.visit("/settings", { preserveScroll: true })}>
Settings
</button>
<script>
import { Link, page, router } from "@kolektiv/keel-svelte"

const ctx = page()
</script>

<Link href="/u/ada" prefetch="hover">{ctx.data.viewer?.displayName}</Link>
<a href="/u/ada" use:keel={{ prefetch: "hover" }}>Ada</a>
<button type="button" onclick={() => router.visit("/settings", { preserveScroll: true })}>
Settings
</button>
import { Link, router, useKeelAnchor, usePage } from "@kolektiv/keel-react"
import type { HomePage } from "@app/page-types"

export default function Viewer() {
const seed = usePage<HomePage>()
const anchor = useKeelAnchor({ href: "/u/ada", prefetch: "hover" })
return (
  <>
    <Link href="/u/ada" prefetch="hover">{seed.data.viewer?.displayName}</Link>
    <a {...anchor}>Ada</a>
    <button type="button" onClick={() => router.visit("/settings", { preserveScroll: true })}>
      Settings
    </button>
  </>
)
}
import { Link, router, useKeelAnchor, usePage } from "@kolektiv/keel-react"

export default function Viewer() {
const seed = usePage()
const anchor = useKeelAnchor({ href: "/u/ada", prefetch: "hover" })
return (
  <>
    <Link href="/u/ada" prefetch="hover">{seed.data.viewer?.displayName}</Link>
    <a {...anchor}>Ada</a>
    <button type="button" onClick={() => router.visit("/settings", { preserveScroll: true })}>
      Settings
    </button>
  </>
)
}
<script setup lang="ts">
import { Link, router, useKeelAnchor, usePage } from "@kolektiv/keel-vue"
import type { HomePage } from "@app/page-types"

const seed = usePage<HomePage>()
const anchor = useKeelAnchor({ href: "/u/ada", prefetch: "hover" })
</script>

<template>
<Link href="/u/ada" prefetch="hover">{{ seed.data.viewer?.displayName }}</Link>
<a v-bind="anchor">Ada</a>
<button type="button" @click="router.visit('/settings', { preserveScroll: true })">
  Settings
</button>
</template>
<script setup>
import { Link, router, useKeelAnchor, usePage } from "@kolektiv/keel-vue"

const seed = usePage()
const anchor = useKeelAnchor({ href: "/u/ada", prefetch: "hover" })
</script>

<template>
<Link href="/u/ada" prefetch="hover">{{ seed.data.viewer?.displayName }}</Link>
<a v-bind="anchor">Ada</a>
<button type="button" @click="router.visit('/settings', { preserveScroll: true })">
  Settings
</button>
</template>
import { Link, router, useKeelAnchor, usePage } from "@kolektiv/keel-solid"
import type { HomePage } from "@app/page-types"

export default function Viewer() {
const seed = usePage<HomePage>()
const anchor = useKeelAnchor({ href: "/u/ada", prefetch: "hover" })
return (
  <>
    <Link href="/u/ada" prefetch="hover">{seed().data.viewer?.displayName}</Link>
    <a {...anchor}>Ada</a>
    <button type="button" onClick={() => router.visit("/settings", { preserveScroll: true })}>
      Settings
    </button>
  </>
)
}
import { Link, router, useKeelAnchor, usePage } from "@kolektiv/keel-solid"

export default function Viewer() {
const seed = usePage()
const anchor = useKeelAnchor({ href: "/u/ada", prefetch: "hover" })
return (
  <>
    <Link href="/u/ada" prefetch="hover">{seed().data.viewer?.displayName}</Link>
    <a {...anchor}>Ada</a>
    <button type="button" onClick={() => router.visit("/settings", { preserveScroll: true })}>
      Settings
    </button>
  </>
)
}
import { Link, router, useKeelAnchor, usePage } from "@kolektiv/keel-preact"
import type { HomePage } from "@app/page-types"

export default function Viewer() {
const seed = usePage<HomePage>()
const anchor = useKeelAnchor({ href: "/u/ada", prefetch: "hover" })
return (
  <>
    <Link href="/u/ada" prefetch="hover">{seed.data.viewer?.displayName}</Link>
    <a {...anchor}>Ada</a>
    <button type="button" onClick={() => router.visit("/settings", { preserveScroll: true })}>
      Settings
    </button>
  </>
)
}
import { Link, router, useKeelAnchor, usePage } from "@kolektiv/keel-preact"

export default function Viewer() {
const seed = usePage()
const anchor = useKeelAnchor({ href: "/u/ada", prefetch: "hover" })
return (
  <>
    <Link href="/u/ada" prefetch="hover">{seed.data.viewer?.displayName}</Link>
    <a {...anchor}>Ada</a>
    <button type="button" onClick={() => router.visit("/settings", { preserveScroll: true })}>
      Settings
    </button>
  </>
)
}
import { html } from "lit"
import { KeelElement, keelAnchor, router } from "@kolektiv/keel-lit"
import type { HomePage } from "@app/page-types"

export default class Viewer extends KeelElement<HomePage> {
render() {
  return html`
    <keel-link href="/u/ada" prefetch="hover">${this.page.data.viewer?.displayName}</keel-link>
    <a ${keelAnchor({ href: "/u/ada", prefetch: "hover" })}>Ada</a>
    <button type="button" @click=${() => router.visit("/settings", { preserveScroll: true })}>
      Settings
    </button>
  `
}
}
import { html } from "lit"
import { KeelElement, keelAnchor, router } from "@kolektiv/keel-lit"

export default class Viewer extends KeelElement {
render() {
  return html`
    <keel-link href="/u/ada" prefetch="hover">${this.page.data.viewer?.displayName}</keel-link>
    <a ${keelAnchor({ href: "/u/ada", prefetch: "hover" })}>Ada</a>
    <button type="button" @click=${() => router.visit("/settings", { preserveScroll: true })}>
      Settings
    </button>
  `
}
}
import { Component } from "@angular/core"
import { KeelAnchorDirective, KeelLink, injectKeelPage, router } from "@kolektiv/keel-angular"
import type { HomePage } from "@app/page-types"

@Component({
selector: "app-viewer",
standalone: true,
imports: [KeelAnchorDirective, KeelLink],
template: `
  <keel-link href="/u/ada" prefetch="hover">{{ page().data.viewer?.displayName }}</keel-link>
  <a [keelAnchor]="{ href: '/u/ada', prefetch: 'hover' }">Ada</a>
  <button type="button" (click)="navigate.visit('/settings', { preserveScroll: true })">
    Settings
  </button>
`,
})
export default class Viewer {
readonly page = injectKeelPage<HomePage>()
readonly navigate = router
}
import { Component } from "@angular/core"
import { KeelAnchorDirective, KeelLink, injectKeelPage, router } from "@kolektiv/keel-angular"

@Component({
selector: "app-viewer",
standalone: true,
imports: [KeelAnchorDirective, KeelLink],
template: `
  <keel-link href="/u/ada" prefetch="hover">{{ page().data.viewer?.displayName }}</keel-link>
  <a [keelAnchor]="{ href: '/u/ada', prefetch: 'hover' }">Ada</a>
  <button type="button" (click)="navigate.visit('/settings', { preserveScroll: true })">
    Settings
  </button>
`,
})
export default class Viewer {
readonly page = injectKeelPage()
readonly navigate = router
}
Form and useForm
<script lang="ts">
import { Form, useForm } from "@kolektiv/keel-svelte"

const comment = useForm({ body: "" })
</script>

<Form action="/p/hello/comments" method="post" resetOnSuccess>
<textarea name="body"></textarea>
<button type="submit">Post</button>
</Form>
<script>
import { Form, useForm } from "@kolektiv/keel-svelte"

const comment = useForm({ body: "" })
</script>

<Form action="/p/hello/comments" method="post" resetOnSuccess>
<textarea name="body"></textarea>
<button type="submit">Post</button>
</Form>
import { Form, useForm } from "@kolektiv/keel-react"

export default function CommentForm() {
const comment = useForm({ body: "" })
return (
  <Form action="/p/hello/comments" method="post" resetOnSuccess>
    <textarea
      name="body"
      value={comment.data.body}
      onInput={(event) => comment.set("body", event.currentTarget.value)}
    />
    <button type="submit">Post</button>
  </Form>
)
}
import { Form, useForm } from "@kolektiv/keel-react"

export default function CommentForm() {
const comment = useForm({ body: "" })
return (
  <Form action="/p/hello/comments" method="post" resetOnSuccess>
    <textarea
      name="body"
      value={comment.data.body}
      onInput={(event) => comment.set("body", event.currentTarget.value)}
    />
    <button type="submit">Post</button>
  </Form>
)
}
<script setup lang="ts">
import { Form, useForm } from "@kolektiv/keel-vue"

const comment = useForm({ body: "" })
</script>

<template>
<Form action="/p/hello/comments" method="post" reset-on-success>
  <textarea
    name="body"
    :value="comment.data.body"
    @input="comment.set('body', $event.target.value)"
  ></textarea>
  <button type="submit">Post</button>
</Form>
</template>
<script setup>
import { Form, useForm } from "@kolektiv/keel-vue"

const comment = useForm({ body: "" })
</script>

<template>
<Form action="/p/hello/comments" method="post" reset-on-success>
  <textarea
    name="body"
    :value="comment.data.body"
    @input="comment.set('body', $event.target.value)"
  ></textarea>
  <button type="submit">Post</button>
</Form>
</template>
import { Form, useForm } from "@kolektiv/keel-solid"

export default function CommentForm() {
const comment = useForm({ body: "" })
return (
  <Form action="/p/hello/comments" method="post" resetOnSuccess>
    <textarea
      name="body"
      value={comment.data.body}
      onInput={(event) => comment.set("body", event.currentTarget.value)}
    />
    <button type="submit">Post</button>
  </Form>
)
}
import { Form, useForm } from "@kolektiv/keel-solid"

export default function CommentForm() {
const comment = useForm({ body: "" })
return (
  <Form action="/p/hello/comments" method="post" resetOnSuccess>
    <textarea
      name="body"
      value={comment.data.body}
      onInput={(event) => comment.set("body", event.currentTarget.value)}
    />
    <button type="submit">Post</button>
  </Form>
)
}
import { Form, useForm } from "@kolektiv/keel-preact"

export default function CommentForm() {
const comment = useForm({ body: "" })
return (
  <Form action="/p/hello/comments" method="post" resetOnSuccess>
    <textarea
      name="body"
      value={comment.data.body}
      onInput={(event) => comment.set("body", event.currentTarget.value)}
    />
    <button type="submit">Post</button>
  </Form>
)
}
import { Form, useForm } from "@kolektiv/keel-preact"

export default function CommentForm() {
const comment = useForm({ body: "" })
return (
  <Form action="/p/hello/comments" method="post" resetOnSuccess>
    <textarea
      name="body"
      value={comment.data.body}
      onInput={(event) => comment.set("body", event.currentTarget.value)}
    />
    <button type="submit">Post</button>
  </Form>
)
}
import { html } from "lit"
import { KeelElement, form, useForm } from "@kolektiv/keel-lit"

export default class CommentForm extends KeelElement {
readonly comment = useForm(this, { body: "" })

render() {
  return form(
    { action: "/p/hello/comments", method: "post", resetOnSuccess: true },
    html`
      <textarea
        name="body"
        .value=${this.comment.data.body}
        @input=${(event: Event) =>
          this.comment.set("body", (event.target as HTMLTextAreaElement).value)}
      ></textarea>
      <button type="submit">Post</button>
    `,
  )
}
}
import { html } from "lit"
import { KeelElement, form, useForm } from "@kolektiv/keel-lit"

export default class CommentForm extends KeelElement {
readonly comment = useForm(this, { body: "" })

render() {
  return form(
    { action: "/p/hello/comments", method: "post", resetOnSuccess: true },
    html`
      <textarea
        name="body"
        .value=${this.comment.data.body}
        @input=${(event) => this.comment.set("body", event.target.value)}
      ></textarea>
      <button type="submit">Post</button>
    `,
  )
}
}
import { Component } from "@angular/core"
import { KeelFormDirective, injectKeelForm } from "@kolektiv/keel-angular"

@Component({
selector: "app-comment-form",
standalone: true,
imports: [KeelFormDirective],
template: `
  <form [keelForm]="{ action: '/p/hello/comments', method: 'post', resetOnSuccess: true }">
    <textarea
      name="body"
      [value]="comment.data.body"
      (input)="comment.set('body', $any($event.target).value)"
    ></textarea>
    <button type="submit">Post</button>
  </form>
`,
})
export default class CommentForm {
readonly comment = injectKeelForm({ body: "" })
}
import { Component } from "@angular/core"
import { KeelFormDirective, injectKeelForm } from "@kolektiv/keel-angular"

@Component({
selector: "app-comment-form",
standalone: true,
imports: [KeelFormDirective],
template: `
  <form [keelForm]="{ action: '/p/hello/comments', method: 'post', resetOnSuccess: true }">
    <textarea
      name="body"
      [value]="comment.data.body"
      (input)="comment.set('body', $any($event.target).value)"
    ></textarea>
    <button type="submit">Post</button>
  </form>
`,
})
export default class CommentForm {
readonly comment = injectKeelForm({ body: "" })
}

Visit options

Mirrored on router.visit, Link, and form helpers.

Option Meaning
replace Swap history (login, pack switch)
preserveScroll true, or "errors" after 422
preserveState Same page id: update(), do not remount
only / except Partial data reload
prefetch hover, mousedown, or mount
viewTransition Optional document.startViewTransition

Prefetch warms both the seed and the entry module. Link prefetches only when you opt in (prefetch="hover", "mousedown", "mount", or true); the default is off. Guards never run for a prefetch.