Routing | Koze | Primitives Docs

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 at src/app.koze (not inside routes/). Optional; the framework provides a default when absent.
  • layout.koze — a fragment that wraps every page. Nest <segment>/layout.koze for 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 .html files are ignored (and should not be used for Koze pages). The .koze extension signals "this is a Koze template with mixed server / client script regions" so editor tooling, the compiler, and agents can treat it correctly. Use .html only for true static HTML served from src/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 / .success async-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 the on<event>={fn(args)} directive.
  • Top-level statements whose bodies contain await are 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} or data-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 declaring let filteredUsers.
  • Top-level let bindings that participate in $:, bind:value, or live template expressions become mutable reactive state. Top-level const bindings 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.