JSX SSR
@ecopages/jsx/server is the dedicated SSR entrypoint for renderToString(...).
It serializes the same JSX authoring output used by the DOM renderer.
The Two SSR Modes
/** @jsxImportSource @ecopages/jsx */
import { renderToString } from '@ecopages/jsx/server';
const view = <button class="action">Ship</button>;
const html = renderToString(view);
const hydratedHtml = renderToString(view, { mode: 'hydrate' });renderToString(view)returns plain HTMLrenderToString(view, { mode: 'hydrate' })returns HTML plus binding markers that the client hydrator can reconnect later
Use mode: 'hydrate' only when the client will hydrate that exact same view.
What Hydrated SSR Adds
Hydrated output embeds binding descriptors for dynamic parts such as:
- event bindings
- property bindings
- boolean and normal attribute bindings
Those markers let hydrate(...) attach listeners and property wiring without rebuilding the existing DOM tree.
Client Attach Step
The normal client follow-up is:
/** @jsxImportSource @ecopages/jsx */
import { createRoot } from '@ecopages/jsx';
function App() {
return <button class="action">Ship</button>;
}
const container = document.querySelector('#app');
if (container instanceof HTMLElement) {
createRoot(container).hydrate(<App />);
}If hydration markers are missing, the client runtime falls back to a normal render.
SSR And Custom Elements
When JSX SSR encounters a registered Radiant custom element, the server custom-element render hook asks Radiant's server pipeline to serialize the host (same path as renderRadiantElementHostToString from @ecopages/radiant/server/radiant-element-ssr). Radiant Element Hosts do not expose a durable instance renderHostToString() API. That instance method remains only the generic third-party custom-element contract understood by @ecopages/jsx/server.
At SSR time the runtime:
- instantiates the registered custom element constructor
- applies normalized attributes and serialized children
- serializes the host through the Radiant server pipeline (light-DOM only)
Event handlers are not serialized into HTML. They reconnect only during client hydration.
See Component SSR for Radiant adapter helpers (renderComponent, install runtime, light-DOM policy).
Server Rendering Rules
on:*andon-native:*bindings are skipped in raw HTML outputprop:*bindings are applied as properties on server-renderable custom element instances- plain text content is escaped
- iterables are serialized by walking their children in order
- subscribable and signal-like values are snapshotted to their current value during SSR
Async SSR Pattern
Async work belongs before the render pass starts, not inside it.
- fetch data, resolve assets, or build view models before calling the SSR helper
- pass resolved values into the component or controller before
renderToString(...)runs - keep
render()synchronous and treat SSR as a snapshot of current state
For example, this is the intended shape:
const product = await api.getProduct(productId);
const rendered = await renderController(ProductController, {
tagName: 'section',
initialize: (controller) => {
controller.product = product;
},
});By contrast, fetching inside render() is not part of the current SSR model.
Orchestration around SSR may still be async on Node. For example, a route handler can await data loading, then call renderToString(...) inside withActiveSsrScopeValue(...). Keep component render() synchronous; treat SSR as a snapshot of resolved state.
SSR Render Scope
renderToString(...) runs inside an active SSR render scope. @ecopages/jsx/server is Node-only and stores that scope in AsyncLocalStorage, so concurrent requests and abandoned async work do not leak state across unrelated renders.
Most app code never touches scope directly. Framework adapters use it to carry shared server state across nested renderToString(...) calls or split entrypoints.
When to use scope helpers
| Need | API |
|---|---|
Share hydrate binding indexes across sibling renderToString(...) calls | createServerHydrationBindingState() + withServerHydrationBindingState(...) |
| Share other framework-owned SSR state (asset frames, runtime bridges, etc.) | withActiveSsrScopeValue(...) + getActiveSsrScopeValue(...) |
Prefer Symbol.for('@your-package.namespace') keys so scope values survive entrypoint boundaries. Await I/O outside the scope when you only need scoped state during a synchronous render snapshot.
Example: sibling renders in one hydration root
/** @jsxImportSource @ecopages/jsx */
import { jsx } from '@ecopages/jsx/jsx-runtime';
import { renderToString, withActiveSsrScopeValue } from '@ecopages/jsx/server';
const SHARED_SCOPE_KEY = Symbol.for('@acme/ssr.shared-root');
const html = withActiveSsrScopeValue(SHARED_SCOPE_KEY, { started: true }, () => {
const page = renderToString(jsx('section', { class: 'page', children: 'Page' }), { mode: 'hydrate' });
const shell = renderToString(jsx('main', { class: 'layout', children: page }), { mode: 'hydrate' });
return shell;
});Hydration binding indexes continue across both renderToString(...) calls because they share one active SSR scope.
Mental Model
The important distinction is:
- SSR is HTML generation
- hydration is the later client attach step
They are related, but not the same operation.
Related APIs
- JSX Client Rendering for
createRoot(...),hydrate(...), andhasHydrationMarkers(...) - RadiantElement for host-owned render lifecycle
- Component SSR for
renderComponent(...)and the server pipeline - Hydration for the current explicit client hydrator contract used by render-owning
RadiantElementSSR pages