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.
Foreign content β ns
The client parses the string in a detached <template>, which is always HTML. The parser only enters SVG or MathML mode when it sees an <svg> / <math> start tag, so an interior fragment on its own gets neither β a bare <path/> lands in the XHTML namespace, sits in the DOM with the right tag name and attributes, and paints nothing.
ns marks the string as interior content of one of those subtrees:
<svg viewBox="0 0 24 24"> <Fragment html ns="svg">{'<path d="M0 0"/>'}</Fragment></svg>The markup is parsed against a detached root element of that namespace and the root is then dropped, so only the interior reaches the DOM. Because the element itself is the parserβs context β not an <svg>β¦</svg> wrapper pasted around the string β markup like "</svg><p>" has nothing to close out of. Accepts "svg" or "mathml"; the server renderer ignores it, since the string is already inside its parent there.
Generated code is the expected caller β a compiler knows the namespace at build time, which is how the SVG plugin uses it. Reach for it by hand only when you are handing raw markup to an <svg> or <math> parent yourself.
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.