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.
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.
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.