Skip to content

Core concepts

Document head and SEO

Packs author +head.svelte (or +head.html); the host renders it into the HTML shell.

Crawlers and share bots GET the real host URL. That document is the SEO unit. A visit (X-Keel-Visit) is pushState plus JSON, social crawlers do not run JS, and visits are not the SEO unit.

The pack authors the head

A page declares its head in a sibling file next to +page.svelte. The placeholder grammar is the same in both templates:

Svelte pages author a sibling +head.svelte next to +page.svelte:

The other six adapters compile a neutral +head.html with the same {seed.*} placeholders and no component wrapper:

<script lang="ts">
import type { KeelSeed } from "@kolektiv/keel"
import type { UserPage } from "@app/page-types"

let { seed }: { seed: KeelSeed<UserPage> } = $props()
</script>

<title>{seed.data.user.displayName}Harbor</title>
<meta name="description" content="Messages from {seed.data.user.displayName}." />
<script>
let { seed } = $props()
</script>

<title>{seed.data.user.displayName} — Harbor</title>
<meta name="description" content="Messages from {seed.data.user.displayName}." />
<title>{seed.data.user.displayName} — Harbor</title>
<meta name="description" content="Messages from {seed.data.user.displayName}." />
<title>{seed.data.user.displayName} — Harbor</title>
<meta name="description" content="Messages from {seed.data.user.displayName}." />
<title>{seed.data.user.displayName} — Harbor</title>
<meta name="description" content="Messages from {seed.data.user.displayName}." />
<title>{seed.data.user.displayName} — Harbor</title>
<meta name="description" content="Messages from {seed.data.user.displayName}." />
<title>{seed.data.user.displayName} — Harbor</title>
<meta name="description" content="Messages from {seed.data.user.displayName}." />
<title>{seed.data.user.displayName} — Harbor</title>
<meta name="description" content="Messages from {seed.data.user.displayName}." />

@kolektiv/keel-pack compiles it at pack build time into the page’s head string in manifest.json. Only head markup survives: <title>, <meta>, <link>, and <script src> (external src, empty body). {seed.*} expressions compile to host placeholders: {seed.page}{{page}}, {seed.path}{{path}}, {seed.params.slug}{{params.slug}}, plus {{data.*}}, {{shared.*}}, and {{theme.id}} / {{theme.version}}. Blocks, {@html}, and non-seed.* expressions fail the pack build. No pack JS runs on the Kotlin host. The manifest field is specified on Pack adapter.

The host renders it

On a document GET the host substitutes the placeholders from the seed (page, path, params, data, shared, theme, the serving pack), allowlists tags and attributes, drops unsafe URLs, and writes real tags into <head>. A tag whose placeholder has no value in the seed is dropped whole — other tags still render, and a document GET never throws. A tag that carries an on* handler attribute, or a <script> with an inline body or no sanitized src, is dropped too; only external scripts survive.

PageRequest.head(...) fills the fields a pack head omits, and is the whole head when the pack ships no +head.svelte / +head.html. Canonical defaults to the document URL.

Fallback loader head
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))
}

CSP nonces

Set KeelConfig.csp to send a Content-Security-Policy on document responses. Keel generates one nonce per document (16 random bytes, Base64 URL-safe) and stamps it on the shell scripts (#__keel_seed, the bootstrap module, fallback application/ld+json) and on pack-declared <script src> / <link> tags. Pack-supplied nonce attributes are stripped, so a pack cannot forge one.

Opt-in CSP
keel {
  csp = CspPolicy.nonce()
}

CspPolicy.nonce() emits script-src 'nonce-…' 'strict-dynamic'; style-src 'self'; object-src 'none'; base-uri 'none'. Without csp no header is sent, and documents still carry a nonce. Visits return JSON only: no nonce and no CSP header.

Visits stay JSON

A visit returns the same resolved head on seed.head, including html. The client applies it in place, so document.head keeps exactly one <title> after navigation and the SPA matches the crawler document.

A document GET of /u/{id} contains the display name in <title> without executing JavaScript. That is the test that matters for share bots.