Routing
File-based routing, layouts, template syntax, and client reactivity
Route conventions
Place .koze files inside src/routes/. File paths become URL patterns.
src/app.koze -> document shell (<!DOCTYPE>, <html>, <head>)
src/routes/layout.koze -> shared fragment wrapping every page
src/routes/index.koze -> /
src/routes/about/index.koze -> /about
src/routes/items/index.koze -> /items
src/routes/blog/[slug]/index.koze -> /blog/:slug
API endpoints use a separate convention: place .ts or .js modules under
src/routes/api/. For example, src/routes/api/v1/status.ts maps to
/api/v1/status, and src/routes/api/v1/health/index.ts maps to
/api/v1/health. See API Routes for handler exports,
dynamic segments, and middleware boundaries.
Three file roles, one template syntax:
app.koze— the document shell. Lives atsrc/app.koze(not insideroutes/). Optional; the framework provides a default when absent.layout.koze— a fragment that wraps every page. Nest<segment>/layout.kozefor section-specific chrome.index.koze— the page for a segment. Dynamic segments use[brackets].
All three use the same shape: top <script> + template + <slot></slot> (where applicable). See Project structure for the full reference.
Note: File extension: Route files MUST end in
.koze. The compiler discovers routes by scanning for this extension — plain.htmlfiles are ignored (and should not be used for Koze pages). The.kozeextension signals "this is a Koze template with mixed server / client script regions" so editor tooling, the compiler, and agents can treat it correctly. Use.htmlonly for true static HTML served fromsrc/assets/.
Route execution model
Koze pages are server-rendered by default, but the authored top
<script> model is client-first.
Write the top script as the place where page state, helper functions,
reactive $: code, and $server/* calls live. The framework then:
- runs the server-needed parts during SSR
- hydrates awaited SSR values into the browser copy of the script
- rewrites
$server/*imports to browser RPC stubs on the client side - preserves non-awaited
.pending/.error/.successasync-value behavior
<script>
import { getItems } from '$server/items';
const items = await getItems();
function onDelete(id) {
// Runs in the browser when a button is clicked.
}
</script>
<ul>
for (const item of items) {
<li>
{item.title}
<button>Delete</button>
</li>
}
</ul>
What runs where:
await $server/*functions execute on the server at render time. The resolved values are serialized into the HTML response and hydrated into the browser copy of the script — no second round-trip for SSR data.- Non-awaited
$server/*calls return async values exposing.pending,.error, and.success. On the server they participate in streaming boundaries; in the browser the same shape is preserved by RPC stubs. - Helper functions (
function onDelete(id)) and synchronous top-level code stay in the browser copy of the script. The framework wraps DOM listeners automatically via theon<event>={fn(args)}directive. - Top-level statements whose bodies contain
awaitare stripped from the browser bundle after hydration wiring is in place — they already ran server-side.
Use src/server/* for private modules and backend logic. $server/*
functions called from the browser become RPC stubs that POST to a
framework-managed endpoint.
For the full hydration model, dispatch rules, and __kozeReadData
escape hatch, see Client interactivity.
Browser-only code
Routes allow one <script> block: the top script. Keep DOM-only work
inside functions that run in the browser, and move reusable code to
$lib/* modules that are safe to import during SSR.
<script>
import { getItems } from '$server/items';
import { mountCopyButtons } from '$lib/copy-buttons';
const items = await getItems();
function enableCopyButtons() {
mountCopyButtons(document.querySelectorAll('[data-copy]'));
}
</script>
<ul>
for (const item of items) {
<li data-copy={item.secret}>{item.title}</li>
}
</ul>
<button>Enable copy buttons</button>
If a third-party module touches window during import, wrap the import
in a lazy $lib/* helper and call that helper from a browser event.
Layouts
src/routes/layout.koze is a fragment that wraps every page. Not a document — no <!DOCTYPE>, no <html>, no <body>. Those live in app.koze.
<script>
import { url } from 'koze:request';
const current = url.pathname;
</script>
<header>
<a href="/" class={current === '/' ? 'active' : ''}>Home</a>
<a href="/items" class={current.startsWith('/items') ? 'active' : ''}>Items</a>
</header>
<main>
<slot></slot>
</main>
Nested layouts
Drop a layout.koze at any directory depth. It wraps every
route under that directory, composed with the root layout outside
and the page inside.
src/routes/
├─ layout.koze ← wraps every page
├─ index.koze ← /
└─ admin/
├─ layout.koze ← wraps /admin/*
├─ users/
│ └─ index.koze ← /admin/users (root + admin layouts)
└─ settings/
├─ layout.koze ← wraps /admin/settings/*
└─ index.koze ← /admin/settings (root + admin + settings)
Composition at render time goes innermost first: the route's HTML
is wrapped by its nearest layout, the result is wrapped by the next
layout up, and so on until the root layout hands its output to the
app shell. That means an author writing <slot></slot> in a nested
layout receives the already-wrapped child markup — the inner layout
is closer to the content it's decorating than the outer one.
Each layout is a self-contained fragment: its top <script> runs
independently, it has its own SSR data, and it can import its own
$server/* modules. Two layouts in the same chain can import
koze:request and read the same searchParams / params /
pathname; the framework plumbs request state into every layout and
page uniformly.
Use nested layouts for chrome that's scoped to a section — a
sidebar in /admin, a ticket-drawer overlay on /board/*, tab
navigation in /settings — without duplicating it across every
page underneath.
App shell
The document shell (doctype, <html>, <head>, <body>) belongs in src/app.koze — alongside worker.ts and middleware.ts, not inside routes/:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>My App</title>
</head>
<body>
<slot></slot>
</body>
</html>
Koze auto-injects a <link rel="stylesheet"> for src/app.css here — don't author it yourself. See Styling.
Optional. Koze synthesizes a minimal default shell when the file is absent.
Template syntax
Interpolation
<p>{title}</p>
<p>{@html bodyHtml}</p>
<p>{@raw trustedHtml}</p>
Conditionals
if (items.length === 0) {
<p>Nothing here yet.</p>
} else {
<p>{items.length} items</p>
}
If the condition reads reactive state, the block updates in the browser after the initial server render.
Loops
for (const item of items) {
<li>{item.title}</li>
}
If the iterable reads reactive state, the block updates in the browser after the initial server render. This is the preferred pattern for filtered selects, dependent lists, and other derived UI:
<script>
let selectedLocationId = '';
$: selectOptions = allCells.filter(
cell => cell.locationId === selectedLocationId,
);
</script>
<select bind:value={selectedLocationId}>
for (const location of locations) {
<option value={location.id}>{location.name}</option>
}
</select>
<select>
for (const cell of selectOptions) {
<option value={cell.id}>{cell.name}</option>
}
</select>
Template text and attributes that read reactive state are also live:
<p>{selectOptions.length} available cells</p>
<button disabled={selectOptions.length === 0}>Continue</button>
Loop locals stay available to client bindings:
for (const item of rows) {
<select bind:value={forms[item.id].selected}></select>
}
Components
<script>
import Card from '$lib/card.koze';
</script>
<Card title="Stack">
<p>Live</p>
</Card>
Package components like kuzan/* are optional. Local .koze
components and package components are first-class.
Component Props
Components receive props via koze:component. Destructure in the
component's <script> block:
<!-- src/lib/card.koze -->
<script>
import { props } from 'koze:component';
const { title, class: className = '', variant = 'default' } = props<{
title?: string;
class?: string;
variant?: string;
}>();
</script>
<div class="card {className}" data-variant={variant}>
if (title) {
<h2>{title}</h2>
}
<slot></slot>
</div>
Props patterns:
- Import and destructure with defaults:
const { title, size = 'md' } = props<{ title?: string; size?: string }>(); - Access directly in template:
{props.title}ordata-variant={props.variant} - Use
class:for className (reserved word):const { class: className = '' } = props<{ class?: string }>(); - Children go in
<slot></slot>
Client reactivity
Use $: inside the top <script> when you need reactive updates driven
by client-side state:
<script>
let users = ['Alice'];
$: console.log(`Users: ${users.length}`);
function addUser() {
users.push('Bob');
}
</script>
Rules:
- The top
<script>is authored as client-first code, with SSR-only work extracted and hydrated back into the browser copy. $server/*imports are the server/RPC escape hatch. Awaited on the server, RPC-stubbed in the browser.- You do not need to author
type="module"yourself — the framework adds it when the script has ES module imports. $:is available in the top script and runs in the browser copy of that script.$:assignment targets are defined by the assignment. You can write$: filteredUsers = users.filter(...)without first declaringlet filteredUsers.- Top-level
letbindings that participate in$:,bind:value, or live template expressions become mutable reactive state. Top-levelconstbindings stay readonly, but can be read by reactive state. - Proxy-backed array and object mutation works when
$:is active. - You should not need
typeof document !== 'undefined'guards in the top script. Keep DOM-only work inside browser-called functions, not in top-level module execution.