Client interactivity
Single-script routes, SSR data hydration, and the `on<event>={fn(args)}` directive
Koze routes render on the server, but the authored top <script>
model is client-first. There is one main script to write, one mental
model to hold, and one file to edit when you want to add browser
behavior.
Single-script routes
A route's top <script> block is authored as client code with explicit
server escape hatches. The framework server-runs the parts needed for
SSR, serializes awaited results into the HTML response, and hydrates
those values into the browser copy of the script so references like
boardViews below resolve immediately on first paint.
<script>
import { listBoardViews, getBoard } from '$server/board';
import { searchParams } from 'koze:request';
const slug = searchParams.get('board') || 'default';
const boardViews = await listBoardViews();
const board = await getBoard(slug);
function badgeClass(priority) {
return priority === 'high' ? 'badge-red' : 'badge-gray';
}
function openPrompt(slug) {
const title = window.prompt('Title?');
if (title) location.assign(`/board/${slug}/new?title=${encodeURIComponent(title)}`);
}
</script>
<h1>{board.name}</h1>
<ul>
for (const item of board.items) {
<li class={badgeClass(item.priority)}>{item.title}</li>
}
</ul>
<button>Add item</button>
Note: You author one top
<script>block per route. Add a second<script>block and the compiler reports an error. If a route script gets large, move helpers into$lib/*modules and import them from the top script.
Reactive UI
Use $: for client reactivity. Koze keeps ordinary JavaScript
ordinary until a top-level let is read by $:, bind:value, or a live
template expression.
<script>
const allCells = await listCells();
let selectedLocationId = '';
let selectedCellId = '';
$: selectOptions = allCells.filter(
cell => cell.locationId === selectedLocationId,
);
$: if (!selectOptions.some(cell => cell.id === selectedCellId)) {
selectedCellId = '';
}
</script>
<select bind:value={selectedLocationId}>
<option value="">Choose...</option>
for (const location of locations) {
<option value={location.id}>{location.name}</option>
}
</select>
<select bind:value={selectedCellId}>
<option value="">Choose...</option>
for (const cell of selectOptions) {
<option value={cell.id}>{cell.name}</option>
}
</select>
Reactive reads update these template surfaces in the browser after the server-rendered first paint:
- text expressions like
{selectOptions.length} - normal attributes like
hidden={...},class={...}, andvalue={...} if (...) { ... } else { ... }blocksfor (const item of items) { ... }blocksbind:value={form.field}form controls
bind:value is only the DOM write-back API. $: is still where derived
state and effects live.
Declaring reactive state
Top-level let bindings become reactive state when they are read by
$:, bind:value, or a live template expression. Use them for mutable
source values:
<script>
let search = '';
let selectedLocationId = '';
</script>
Derived values can be declared directly with $:. You do not need to
predeclare a placeholder let first:
<script>
const allCells = await listCells();
let selectedLocationId = '';
$: filteredCells = allCells.filter(
cell => cell.locationId === selectedLocationId,
);
$: hasCells = filteredCells.length > 0;
</script>
<select bind:value={selectedLocationId}>...</select>
if (hasCells) {
for (const cell of filteredCells) {
<p>{cell.name}</p>
}
}
In that example filteredCells and hasCells are both defined by their
$: assignments and exposed to live template blocks. Top-level const
bindings, like allCells, stay readonly; they can be read by reactive
state but are not converted into mutable state themselves.
Inside template loops, use loop locals directly. Koze carries those
locals into client bindings, so bind:value={forms[item.id].selected}
works without DOM traversal.
How hydration works
At render time the framework:
- Runs the top
<script>on the server.await $server/*calls resolve against real modules;koze:request/koze:environmentimports resolve against the live request. - Captures every top-level binding that was populated by an
await(direct or ternary) and every top-levelletthat was mutated inside anawait-bearing block. - Serializes those values into an inline
<script type="application/json" id="__koze_data">element. - Emits a
<script type="module">for the same top-script code, rewritten so thatconst X = await fn()reads becomeconst X = __kozeReadData("X").
In the browser the client copy of the top script runs with those hydrate
reads already wired up — no extra network round-trips for data that
was already computed server-side. Helper functions, $: reactivity,
and synchronous bindings stay available in that browser copy.
<!-- Rendered HTML, abbreviated -->
<script type="application/json" id="__koze_data">
{"boardViews":[...],"board":{...},"__params":{}}
</script>
<script type="module" src="/assets/koze-client-<hash>.js"></script>
The JSON blob is data, not code — browsers never parse it as
JavaScript — so the payload can safely carry arbitrary strings,
including values that contain <script> or </script> fragments.
What gets hydrated
| Pattern | Hydrated? | Notes |
|---|---|---|
const x = await fn() |
Yes | Direct top-level await. |
const x = cond ? await fn() : other |
Yes | Ternary with an await in either branch. |
let x = null; + if (...) { x = await fn(); } |
Yes | let bindings reassigned inside an await-bearing top-level block. |
const x = computeSync() |
No | Pure synchronous code re-runs in the browser. |
function helper() { ... } |
No | Declarations run on both sides. |
| Anything inside a function body | No | Nested await only fires when the function is called. |
Stripping SSR-only code from the browser bundle
The framework strips every top-level statement whose body contains an
await from the browser bundle — those blocks already ran server-side
and their mutations are captured in the hydrate payload. Your event
handlers, helper functions, non-async conditionals, and template
expressions are preserved.
Browser RPC after hydration
When a $server/* import is called in the browser, the generated stub
returns the same thenable async-value contract the server uses for
non-awaited calls:
<script>
import { refreshBoard } from '$server/board';
async function onRefresh() {
const result = refreshBoard();
if (result.pending) {
console.log('Refreshing…');
}
await result;
}
</script>
That means you can either await refreshBoard() for blocking flow or
inspect .pending, .error, and .success immediately for reactive UI.
Reading additional SSR values
Sometimes you want SSR to compute a value the template doesn't need but
a client handler does. Use __kozeReadData, the runtime helper the
framework injects when hydration is in play:
<script>
import { listUsers } from '$server/users';
const users = await listUsers();
const now = Date.now();
function greet() {
// `users` is hydrated (from `await`). `now` is not — it re-runs
// client-side. Read any extra SSR value explicitly by key:
const firstName = __kozeReadData('users')[0]?.name;
alert(`Hi ${firstName}, current time ${now}`);
}
</script>
<button>Greet</button>
Event handler directive
Author DOM handlers declaratively with on<event>={expr}:
<button type="button">Archive</button>
<input type="text" />
<form>...</form>
<dialog>...</dialog>
Supported events: click, change, input, submit, keydown,
keyup, focus, blur.
The expr between {…} must be a call expression:
<!-- ✅ Supported -->
<button>Save</button>
<button>Save</button>
<button 'x')}>Save</button>
<button>Save</button>
<!-- ❌ Not supported — arrow bodies, statements, bare identifiers -->
<button => save(item.id)}>Save</button>
<button>Save</button>
<button track()}>Save</button>
Dispatch rules
The framework picks the dispatch path based on what the callee is:
| Callee shape | Dispatch | What runs |
|---|---|---|
$server/* import (e.g. deleteItem imported from $server/items) |
Server action — POSTs to the route URL | The route's actions[fnName] server function, invoked as fn(...args, ctx) where ctx is { formData, request, url, params }. See Actions for the full signature. |
Function declared in the top <script> OR $lib/* import |
Client handler — runs in the browser | The function, with the args evaluated at SSR time. |
| Anything else (bare expression) | Native inline — legacy escape hatch | The literal expression in an inline onclick=. Requires CSP unsafe-inline. |
The args (item.id, filter.key, …) are evaluated at SSR time — they
are plain serialized values by the time the browser sees them. To access
the DOM event, receive it as a trailing parameter in the handler
function:
<script>
function handleDialogKey(event) {
if (event.key === 'Escape') event.currentTarget.close();
}
</script>
<dialog>...</dialog>
The bridge passes (…args, event, element) to every handler, so an
author-declared function with fewer parameters just ignores the
trailing ones.
Client handler vs. direct server-action dispatch
Both paths work. Pick based on what the interaction needs:
<!-- Direct server-action dispatch: one line. Framework POSTs, the
browser re-renders on success. Use for fire-and-refresh clicks
where you don't need UI between the click and the response. -->
<button>Delete</button>
<!-- Client handler wrapper: use when you need confirmation, optimistic
updates, inline error display, or anything between the click and
the network round-trip. `$server/*` imports are real functions in
the browser bundle — the framework auto-generates an RPC stub —
so calling `await deleteItem(id)` works inside the handler. -->
<script>
import { deleteItem } from '$server/items';
async function onDelete(id) {
if (!confirm('Delete?')) return;
await deleteItem(id);
location.reload();
}
</script>
<button>Delete</button>
The server side is the same function in both cases. See Actions for the server function signature.
Client bridge
When a route uses on<event>={…}, the framework injects a ~1 kB bridge
into the route's client bundle. The bridge:
- Registers a single document-level listener per event type.
- Looks up the ancestor carrying
data-client-*attributes. - Dispatches to the handler registered via
window.__kozeClient.register(routeId, {…})for client handlers. - POSTs
_action/_argsto the route URL for server-action callees ($server/*imports).
Handler expressions are stored in a per-route table keyed by a short
id (h0, h1, …) and referenced from the HTML via
data-client-handler="h0". Handler bodies live in the client bundle —
data-client-args="[1]" only carries the serialized args.
The bridge is idempotent: multiple registrations against the same
routeId merge their handler tables, so HMR and client-side
navigations that recycle the document don't double-bind.
Security
- The JSON hydration payload escapes
<to\u003cso an attacker can't close the<script>tag early with user-supplied data. - Handler ids are validated against a strict identifier regex and
prototype-pollution names (
__proto__,constructor,prototype) are blocked at both register and invoke time. - Server-action POSTs are same-origin and include the action name from a trusted compile-time attribute, not a user-controlled identifier.
- Native inline
onclick="expr"(the legacy escape hatch) requires a CSP that allowsunsafe-inline. The declarativeonclick={expr}directive does not — the bridge is a normal same-origin module script.
Browser-only DOM code
Route files allow one <script> block: the top script. That script is
authored client-first, but it must still be safe for SSR analysis and
initial render. Keep direct DOM access inside event handlers or lazily
called functions, not in top-level module execution:
<script>
import { getItems } from '$server/items';
import { mountWidget } from '$lib/widget';
const items = await getItems();
function activateWidget() {
mountWidget(document.querySelector('[data-widget]'));
}
</script>
<div data-widget>...</div>
<button>Activate widget</button>
Modules imported from $lib/* should be SSR-safe at import time. If a
third-party browser library touches window during import, wrap it in a
small $lib/* helper that loads it lazily inside the browser-only
function.