Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Server-Side Rendering

A single Kāra program often compiles to more than one target. The classic case is server-side rendering (SSR): the server renders a page to HTML, the browser hydrates it and handles interaction — and you want one component to drive both, not two copies that drift apart.

Kāra does this without #[cfg] chains in your component. The component stays an ordinary, target-agnostic function. What differs between server and client is which provider you bind for its resources.

The full, runnable code for this chapter is examples/ssr_counter.

The shared component

The component renders against an abstract resource, Sink, rather than talking to a concrete HTML buffer or DOM. It has no idea which target it is running on — and no #[target(...)] attribute:

effect resource Sink;

// Target-agnostic: compiles unchanged for the server and the client.
pub fn render_counter(count: i64) with writes(Sink) {
    Sink.heading("Kāra SSR Counter");
    Sink.count(count);
    Sink.parity(count % 2);
}

render_counter issues semantic render calls. Turning those into bytes or DOM mutations is somebody else's job — the provider's.

Two providers, one resource

A provider is just a type whose methods realize the resource. On the server, Sink becomes HTML:

struct StringSink {}
impl StringSink {
    fn heading(mut ref self, title: String) { print(f"<h1>{title}</h1>"); }
    fn count(mut ref self, n: i64) { print(f"<output id=\"count\">{n}</output>"); }
    fn parity(mut ref self, p: i64) {
        if p == 0 { println("<span id=\"parity\">even</span>"); }
        else { println("<span id=\"parity\">odd</span>"); }
    }
}

On the client, Sink becomes DOM mutation. The static heading is already present in the page the server rendered, so hydration leaves it alone — only the dynamic values cross to the host:

effect resource Dom;
host fn dom_set_count(value: i64) with writes(Dom);
host fn dom_set_parity(value: i64) with writes(Dom);

struct DomSink {}
impl DomSink {
    fn heading(mut ref self, title: String) {}  // already in the SSR'd DOM
    fn count(mut ref self, n: i64) with writes(Dom) { dom_set_count(n); }
    fn parity(mut ref self, p: i64) with writes(Dom) { dom_set_parity(p); }
}

The entry points — the only place #[target] belongs

Each target binds its provider with with_provider, then calls the same component. The entry points are the one genuinely per-target part of the program, so they — and only they — carry #[target(...)]:

// Server: render to HTML on stdout.
#[target(native)]
fn main() {
    with_provider[Sink](StringSink {}, || {
        render_counter(42);
    });
}

// Client: hydrate the live DOM. `pub` + a matching target tag exports it
// to JavaScript.
#[target(wasm_browser)]
pub fn hydrate(count: i64) -> i64 with writes(Dom) {
    with_provider[Sink](DomSink {}, || {
        render_counter(count);
    });
    count
}

Build each target from the same file:

karac build ssr_counter.kara                       # ./ssr_counter (server)
karac build ssr_counter.kara --target=wasm_browser # .wasm + .js (client)

The server prints the page body; the browser loads the wasm, supplies the DOM host fns, and calls hydrate. One component, rendered two ways.

The rule

Keep #[target(...)] out of component bodies. The attribute is for entry points and irreducible forks — code that genuinely cannot exist on every target. Everything else is target-agnostic, and per-target behavior comes from the providers you bind.

This is not just style. Because the component is target-agnostic, the compiler type-checks and effect-checks it once per target (see the design notes on cross-target compilation). A user-defined resource like Sink has no target affinity — it lives wherever a provider for it does — so the same component is provably correct on the server and the client without a single conditional.

The effect system also catches target mistakes for you. A function that reaches a browser-only capability (say writes(Display)) cannot be compiled for native; the compiler rejects it at the target gate and points to the call chain — no silent misbuild, no runtime surprise.