Skip to content

Server Rendering

elements-kit/server renders the same JSX you write for the browser to an HTML stream β€” in Node, Cloudflare Workers, or any JavaScript runtime, with no DOM. elements-kit/hydrate then adopts that HTML on the client and makes it interactive without rebuilding it.

Both entries are experimental: APIs may change between minor versions.

Rendering on the server

Pass a thunk, not evaluated JSX β€” JSX evaluates eagerly, so the server renderer must install before the first jsx call runs.

import { renderToString } from "elements-kit/server";
const html = await renderToString(() => <App />);

For streaming responses use renderToStream, which returns a standard ReadableStream<Uint8Array>:

import { renderToStream } from "elements-kit/server";
export default {
fetch: () =>
new Response(renderToStream(() => <App />), {
headers: { "content-type": "text/html" },
}),
};

Snapshot semantics

A server render is a one-shot snapshot of your reactive state:

  • Signals and computeds are read once, without subscribing.
  • Effects do not run β€” they are client-only.
  • on: handlers and ref callbacks are skipped; interactivity attaches at hydration.
  • innerHTML throws β€” there is no raw HTML sink. Text and attributes are escaped.

Async data

promise and async reactive values used as children become async insertion points: everything before them flushes immediately, then the stream awaits the value and continues.

import { promise } from "elements-kit/utilities/promise";
const user = promise(fetch("/api/user").then((r) => r.json()));
const App = () => (
<main>
<h1>Profile</h1> {/* flushes immediately */}
<p>{user}</p> {/* stream awaits, then continues */}
</main>
);

Resolved values are also serialized into a <script type="application/json" id="ek-data"> tag at the end of the stream. On the client, hydrate seeds pending promise/async values from this data β€” the server value displays immediately, and the real client-side settlement overwrites it when it arrives (stale-while-revalidate). Rejected values abort the stream β€” errors propagate, never swallowed.

Prefer async + .run() for server-rendered data. run() calls made while hydration evaluates the tree are deferred; when the value was seeded from ek-data, the fetcher is never invoked β€” no duplicate request:

import { async } from "elements-kit/utilities/async";
const user = async(() => fetch("/api/user").then((r) => r.json()));
const App = () => {
user.run(); // server: awaited + serialized Β· hydration: skipped when seeded
return <p>{user}</p>;
};

promise(fetch(...)) cannot skip the request β€” the fetch fires before the library ever sees it β€” and .start() always executes, because reactive re-runs need the first execution to collect dependencies.

Hydrating on the client

import { hydrate } from "elements-kit/hydrate";
const { dispose } = hydrate(document.getElementById("app")!, () => <App />);

Hydration re-executes the component tree in claim mode: instead of creating DOM, the runtime walks the server-rendered nodes and adopts them β€”

  • static elements and text keep their identity (no rebuild, no flicker),
  • on: handlers attach to the claimed nodes,
  • dynamic children ({() => sig()}) bind live to the server-emitted comment markers,
  • <For> adopts each keyed row and reconciles later updates in place,
  • async children keep their server-rendered content visible until the client value settles.

There is no compiler and no serialized closures β€” your handlers are ordinary closures recreated by re-execution.

Matching trees

The server and client must render the same tree. If a node doesn’t match, that subtree is discarded and rendered fresh; pass onMismatch to observe it:

hydrate(container, () => <App />, {
onMismatch: ({ expected, found }) => report(expected, found),
});

Raw HTML β€” <Fragment html>

The one deliberate raw-HTML sink in the library: pass html on a fragment and the child β€” a MaybeReactive<string> β€” renders as markup instead of text:

const markdown = signal("<h2>rendered</h2><p>from a CMS</p>");
<article>
<Fragment html>{markdown}</Fragment>
</article>

The region lives between slot markers, so it server-renders, hydrates (server content is kept until the source changes), and re-renders when the signal updates. Parsing is script-inert β€” <script> tags in the string never execute.

Sanitize untrusted input at the call site. The string is emitted verbatim on the server and parsed as markup on the client; attribute-based XSS (onerror=…, javascript: URLs) is your responsibility to strip. Everywhere else strings remain text-only, and the innerHTML prop still throws.

Lazy loading & Await

Code splitting composes from primitives you already have β€” async(() => import("./chart")) β€” and <Await> from elements-kit/await (the Suspense equivalent) adds the loading boundary: the server stream awaits dynamic imports in order (the fallback never server-renders), the client shows the fallback until the chunk lands, and hydration keeps the server content visible. See the Astro page for a live demo.

Astro

Using Astro? The Astro integration packages this renderer pair as an island framework β€” <Counter client:load /> server-renders through renderToStream and hydrates through hydrate, with no manual wiring.

Not in v1

  • Custom elements / Declarative Shadow DOM rendering.
  • Class components other than For.
  • Out-of-order (Suspense-style) streaming.
  • Qwik-style resumability β€” hydration re-executes component code by design.