Skip to content

Core concepts

Server actions

Typed writes. JSON in, JSON out. The seed stays the read model.

Actions are typed RPC. They are not pages. They are not a second read model.

The host writes a Kotlin function: one @Serializable input, one @Serializable output. Ktor binds POST /__keel/action/{id}.

@KeelAction
@KeelType
@Serializable
data class SetNameIn(val displayName: String)

@KeelType
@Serializable
data class SetNameOut(val user: UserRef)

object Board {
  @KeelAction("harbor.setName")
  fun ApplicationCall.setName(input: SetNameIn): SetNameOut {
      val trimmed = input.displayName.trim()
      if (trimmed.length < 2) {
          throw PageValidationException(
              mapOf("displayName" to listOf("Display name must be 2 to 40 characters.")),
          )
      }
      // ...
      return SetNameOut(user.toRef())
  }
}

keel {
  actions(Board)
}

Make the function an extension on ApplicationCall when it needs cookies or session; this is the current request. Validation throws PageValidationException and becomes 422 { "errors": { … } }. Success is 200 { "data": … }. CSRF and status codes are on the wire protocol.

The pack calls the id — never a URL it invented:

action()
import { action } from "@kolektiv/keel"

const out = await action<SetNameIn, SetNameOut>("harbor.setName", {
displayName: "Ada",
})
import { action } from "@kolektiv/keel"

const out = await action("harbor.setName", {
displayName: "Ada",
})

useAction(id) wraps that in a mutation that reloads the current seed:

useAction
<script lang="ts">
import { ActionError, page, useAction } from "@kolektiv/keel-svelte"
import type { HomePage, SetNameIn, SetNameOut } from "@app/page-types"

const ctx = page<HomePage>()
const setName = useAction<SetNameIn, SetNameOut>("harbor.setName")
let displayName = $state(ctx.data.viewer?.displayName ?? "")

function onSetName(event: SubmitEvent) {
  event.preventDefault()
  void setName.mutateAsync({ displayName }).catch(() => undefined)
}
</script>

<form onsubmit={onSetName}>
<input bind:value={displayName} />
{#if setName.error instanceof ActionError}
  <p>{setName.error.errors.displayName?.join(" ")}</p>
{/if}
<button type="submit" disabled={setName.isPending}>Save</button>
</form>
<script>
import { ActionError, page, useAction } from "@kolektiv/keel-svelte"

const ctx = page()
const setName = useAction("harbor.setName")
let displayName = $state(ctx.data.viewer?.displayName ?? "")

function onSetName(event) {
  event.preventDefault()
  void setName.mutateAsync({ displayName }).catch(() => undefined)
}
</script>

<form onsubmit={onSetName}>
<input bind:value={displayName} />
{#if setName.error instanceof ActionError}
  <p>{setName.error.errors.displayName?.join(" ")}</p>
{/if}
<button type="submit" disabled={setName.isPending}>Save</button>
</form>
import { useState, type FormEvent } from "react"
import { ActionError, useAction, usePage } from "@kolektiv/keel-react"
import type { HomePage, SetNameIn, SetNameOut } from "@app/page-types"

export default function SetName() {
const seed = usePage<HomePage>()
const setName = useAction<SetNameIn, SetNameOut>("harbor.setName")
const [displayName, setDisplayName] = useState(seed.data.viewer?.displayName ?? "")

function onSetName(event: FormEvent) {
  event.preventDefault()
  void setName.mutateAsync({ displayName }).catch(() => undefined)
}

return (
  <form onSubmit={onSetName}>
    <input value={displayName} onInput={(event) => setDisplayName(event.currentTarget.value)} />
    {setName.error instanceof ActionError && (
      <p>{setName.error.errors.displayName?.join(" ")}</p>
    )}
    <button type="submit" disabled={setName.isPending}>Save</button>
  </form>
)
}
import { useState } from "react"
import { ActionError, useAction, usePage } from "@kolektiv/keel-react"

export default function SetName() {
const seed = usePage()
const setName = useAction("harbor.setName")
const [displayName, setDisplayName] = useState(seed.data.viewer?.displayName ?? "")

function onSetName(event) {
  event.preventDefault()
  void setName.mutateAsync({ displayName }).catch(() => undefined)
}

return (
  <form onSubmit={onSetName}>
    <input value={displayName} onInput={(event) => setDisplayName(event.currentTarget.value)} />
    {setName.error instanceof ActionError && (
      <p>{setName.error.errors.displayName?.join(" ")}</p>
    )}
    <button type="submit" disabled={setName.isPending}>Save</button>
  </form>
)
}
<script setup lang="ts">
import { computed, ref } from "vue"
import { ActionError, useAction, usePage } from "@kolektiv/keel-vue"
import type { HomePage, SetNameIn, SetNameOut } from "@app/page-types"

const seed = usePage<HomePage>()
const setName = useAction<SetNameIn, SetNameOut>("harbor.setName")
const displayName = ref(seed.value.data.viewer?.displayName ?? "")
const errorText = computed(() => {
const error = setName.error.value
return error instanceof ActionError ? error.errors.displayName?.join(" ") : undefined
})

function onSetName() {
void setName.mutateAsync({ displayName: displayName.value }).catch(() => undefined)
}
</script>

<template>
<form @submit.prevent="onSetName">
  <input v-model="displayName" />
  <p v-if="errorText">{{ errorText }}</p>
  <button type="submit" :disabled="setName.isPending">Save</button>
</form>
</template>
<script setup>
import { computed, ref } from "vue"
import { ActionError, useAction, usePage } from "@kolektiv/keel-vue"

const seed = usePage()
const setName = useAction("harbor.setName")
const displayName = ref(seed.value.data.viewer?.displayName ?? "")
const errorText = computed(() => {
const error = setName.error.value
return error instanceof ActionError ? error.errors.displayName?.join(" ") : undefined
})

function onSetName() {
void setName.mutateAsync({ displayName: displayName.value }).catch(() => undefined)
}
</script>

<template>
<form @submit.prevent="onSetName">
  <input v-model="displayName" />
  <p v-if="errorText">{{ errorText }}</p>
  <button type="submit" :disabled="setName.isPending">Save</button>
</form>
</template>
import { createSignal } from "solid-js"
import { ActionError, useAction, usePage } from "@kolektiv/keel-solid"
import type { HomePage, SetNameIn, SetNameOut } from "@app/page-types"

export default function SetName() {
const seed = usePage<HomePage>()
const setName = useAction<SetNameIn, SetNameOut>("harbor.setName")
const [displayName, setDisplayName] = createSignal(seed().data.viewer?.displayName ?? "")

function onSetName(event: SubmitEvent) {
  event.preventDefault()
  void setName.mutateAsync({ displayName: displayName() }).catch(() => undefined)
}

return (
  <form onSubmit={onSetName}>
    <input value={displayName()} onInput={(event) => setDisplayName(event.currentTarget.value)} />
    {setName.error instanceof ActionError && (
      <p>{setName.error.errors.displayName?.join(" ")}</p>
    )}
    <button type="submit" disabled={setName.isPending}>Save</button>
  </form>
)
}
import { createSignal } from "solid-js"
import { ActionError, useAction, usePage } from "@kolektiv/keel-solid"

export default function SetName() {
const seed = usePage()
const setName = useAction("harbor.setName")
const [displayName, setDisplayName] = createSignal(seed().data.viewer?.displayName ?? "")

function onSetName(event) {
  event.preventDefault()
  void setName.mutateAsync({ displayName: displayName() }).catch(() => undefined)
}

return (
  <form onSubmit={onSetName}>
    <input value={displayName()} onInput={(event) => setDisplayName(event.currentTarget.value)} />
    {setName.error instanceof ActionError && (
      <p>{setName.error.errors.displayName?.join(" ")}</p>
    )}
    <button type="submit" disabled={setName.isPending}>Save</button>
  </form>
)
}
import { useState } from "preact/hooks"
import { ActionError, useAction, usePage } from "@kolektiv/keel-preact"
import type { HomePage, SetNameIn, SetNameOut } from "@app/page-types"

export default function SetName() {
const seed = usePage<HomePage>()
const setName = useAction<SetNameIn, SetNameOut>("harbor.setName")
const [displayName, setDisplayName] = useState(seed.data.viewer?.displayName ?? "")

return (
  <form
    onSubmit={(event) => {
      event.preventDefault()
      void setName.mutateAsync({ displayName }).catch(() => undefined)
    }}
  >
    <input value={displayName} onInput={(event) => setDisplayName(event.currentTarget.value)} />
    {setName.error instanceof ActionError && (
      <p>{setName.error.errors.displayName?.join(" ")}</p>
    )}
    <button type="submit" disabled={setName.isPending}>Save</button>
  </form>
)
}
import { useState } from "preact/hooks"
import { ActionError, useAction, usePage } from "@kolektiv/keel-preact"

export default function SetName() {
const seed = usePage()
const setName = useAction("harbor.setName")
const [displayName, setDisplayName] = useState(seed.data.viewer?.displayName ?? "")

return (
  <form
    onSubmit={(event) => {
      event.preventDefault()
      void setName.mutateAsync({ displayName }).catch(() => undefined)
    }}
  >
    <input value={displayName} onInput={(event) => setDisplayName(event.currentTarget.value)} />
    {setName.error instanceof ActionError && (
      <p>{setName.error.errors.displayName?.join(" ")}</p>
    )}
    <button type="submit" disabled={setName.isPending}>Save</button>
  </form>
)
}
import { html } from "lit"
import { ActionError, KeelElement, useAction } from "@kolektiv/keel-lit"
import type { HomePage, SetNameIn, SetNameOut } from "@app/page-types"

export default class SetName extends KeelElement<HomePage> {
readonly setName = useAction<SetNameIn, SetNameOut>(this, "harbor.setName")
private displayName = this.page.data.viewer?.displayName ?? ""

render() {
  return html`
    <form
      @submit=${(event: SubmitEvent) => {
        event.preventDefault()
        void this.setName.mutateAsync({ displayName: this.displayName }).catch(() => undefined)
      }}
    >
      <input
        .value=${this.displayName}
        @input=${(event: Event) =>
          (this.displayName = (event.target as HTMLInputElement).value)}
      />
      ${this.setName.error instanceof ActionError
        ? html`<p>${this.setName.error.errors.displayName?.join(" ")}</p>`
        : ""}
      <button type="submit" ?disabled=${this.setName.isPending}>Save</button>
    </form>
  `
}
}
import { html } from "lit"
import { ActionError, KeelElement, useAction } from "@kolektiv/keel-lit"

export default class SetName extends KeelElement {
readonly setName = useAction(this, "harbor.setName")
displayName = this.page.data.viewer?.displayName ?? ""

render() {
  return html`
    <form
      @submit=${(event) => {
        event.preventDefault()
        void this.setName.mutateAsync({ displayName: this.displayName }).catch(() => undefined)
      }}
    >
      <input
        .value=${this.displayName}
        @input=${(event) => (this.displayName = event.target.value)}
      />
      ${this.setName.error instanceof ActionError
        ? html`<p>${this.setName.error.errors.displayName?.join(" ")}</p>`
        : ""}
      <button type="submit" ?disabled=${this.setName.isPending}>Save</button>
    </form>
  `
}
}
import { Component } from "@angular/core"
import { ActionError, injectKeelAction, injectKeelPage } from "@kolektiv/keel-angular"
import type { HomePage, SetNameIn, SetNameOut } from "@app/page-types"

@Component({
selector: "app-set-name",
standalone: true,
template: `
  <form (submit)="onSetName($event)">
    <input [value]="displayName" (input)="displayName = $any($event.target).value" />
    @if (errorText) {
      <p>{{ errorText }}</p>
    }
    <button type="submit" [disabled]="setName.isPending()">Save</button>
  </form>
`,
})
export default class SetName {
readonly page = injectKeelPage<HomePage>()
readonly setName = injectKeelAction<SetNameIn, SetNameOut>("harbor.setName")
displayName = this.page().data.viewer?.displayName ?? ""

get errorText(): string | undefined {
  const error = this.setName.error()
  return error instanceof ActionError ? error.errors.displayName?.join(" ") : undefined
}

onSetName(event: Event) {
  event.preventDefault()
  void this.setName.mutateAsync({ displayName: this.displayName }).catch(() => undefined)
}
}
import { Component } from "@angular/core"
import { ActionError, injectKeelAction, injectKeelPage } from "@kolektiv/keel-angular"

@Component({
selector: "app-set-name",
standalone: true,
template: `
  <form (submit)="onSetName($event)">
    <input [value]="displayName" (input)="displayName = $any($event.target).value" />
    @if (errorText) {
      <p>{{ errorText }}</p>
    }
    <button type="submit" [disabled]="setName.isPending()">Save</button>
  </form>
`,
})
export default class SetName {
readonly page = injectKeelPage()
readonly setName = injectKeelAction("harbor.setName")
displayName = this.page().data.viewer?.displayName ?? ""

get errorText() {
  const error = this.setName.error()
  return error instanceof ActionError ? error.errors.displayName?.join(" ") : undefined
}

onSetName(event) {
  event.preventDefault()
  void this.setName.mutateAsync({ displayName: this.displayName }).catch(() => undefined)
}
}

Typegen emits the in/out types next to the page map:

export interface HarborActions {
  "harbor.setName": { in: SetNameIn; out: SetNameOut }
  "harbor.postMessage": { in: PostMessageIn; out: PostMessageOut }
}
export type HarborActionId = keyof HarborActions

See Mutate and rehydrate for the invalidation rule.