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 andrefcallbacks are skipped; interactivity attaches at hydration.innerHTMLthrows β 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.