Server-Side Rendering
What SSR Solves
Without SSR, the browser downloads an empty HTML shell, loads JavaScript, then renders the page. Users see a blank screen until JS executes. Search engines may not index the content.
With SSR, the server sends fully-rendered HTML. The browser shows content immediately. JavaScript loads in the background and "hydrates" the static HTML — attaching event listeners and signal bindings to make it interactive.
CellUI's SSR Model
CellUI provides two functions:
serverView— renders a template to an HTML string on the server (no DOM required)hydrate— reconnects signals and event listeners to existing server-rendered DOM on the client
serverView()
serverView is the server-side equivalent of view. Same tagged template syntax, but instead of creating DocumentFragment nodes, it produces an HTML string with hydration markers:
typescript// server.ts — runs in Bun/Node
import { serverView, signal } from '@cmj/cellui';
const count = signal(0);
const html = serverView`
<div>
<h1>Count: ${count}</h1>
<button onclick="${() => count.value++}">+</button>
</div>
`;
console.log(html.toString());Output:
html<div>
<h1>Count: <!--cellui-marker:0-->0</h1>
<button data-cellui-on-1="">+</button>
</div>Notice:
- Signal values are rendered as text with
<!--cellui-marker:N-->comments before them - Event handlers are stripped and replaced with
data-cellui-on-Nattributes (functions can't serialize to HTML) - Nested components get
<!--cellui-open:N-->/<!--cellui-close:N-->boundary markers
hydrate()
On the client, hydrate() walks the server-rendered DOM, finds the markers, and reconnects:
typescript// client.ts — runs in the browser
import { hydrate, view, signal } from '@cmj/cellui';
const count = signal(0);
hydrate(document.getElementById('app')!, () => {
// Re-run the same component — hydrate() intercepts view() calls
// and reconnects to existing DOM instead of creating new nodes
return view`
<div>
<h1>Count: ${count}</h1>
<button onclick="${() => count.value++}">+</button>
</div>
`;
});After hydration:
- The
<!--cellui-marker:0-->is found, and the text node after it is bound tocountviasignal.bindNode() - The
data-cellui-on-1attribute is found, and the originalonclickhandler is attached to the button - The page is now interactive — clicking "+" updates the count
Full example with Bun.serve()
typescript// server.ts
import { serverView, signal } from '@cmj/cellui';
Bun.serve({
port: 3000,
fetch(req) {
const count = signal(0); // fresh state per request
const appHtml = serverView`
<h1>Count: ${count}</h1>
<button onclick="${() => count.value++}">+</button>
`;
return new Response(`
<!DOCTYPE html>
<html>
<body>
<div id="app">${appHtml}</div>
<script type="module" src="/client.js"></script>
</body>
</html>
`, { headers: { 'content-type': 'text/html' } });
}
});typescript// client.ts
import { hydrate, view, signal } from '@cmj/cellui';
const count = signal(0);
hydrate(document.getElementById('app')!, () =>
view`
<h1>Count: ${count}</h1>
<button onclick="${() => count.value++}">+</button>
`
);Isomorphic components
To share a component between server and client, accept the template function as a parameter:
typescript// app.ts — shared
export function App(tmpl: typeof view | typeof serverView) {
const count = signal(0);
return tmpl`<h1>Count: ${count}</h1>`;
}
// server.ts
App(serverView);
// client.ts
hydrate(el, () => App(view));What serverView handles
| Feature | Supported |
|---|---|
| Signal interpolation | Yes — rendered as text with marker comments |
| Event handlers | Yes — stripped to data attributes, re-attached on hydrate |
bind() directive |
Yes — serialized as value attribute + data attribute |
| Computed functions | Yes — evaluated once, marked for hydration |
| Nested components | Yes — wrapped in open/close boundary markers |
each() / when() |
Yes — both check typeof document and fall back to HTML string mode |
| Arrays | Yes — joined with list boundary markers |
What SSR does NOT do
- No streaming —
serverViewgenerates the full HTML string synchronously. No progressive rendering. - No selective hydration —
hydrate()processes the entire container. You can't hydrate individual islands. - No server components — there's no equivalent to React Server Components. All components run on both sides.
- No async SSR — if your component fetches data, you must fetch before calling
serverView, not inside it.
When to use SSR
Use it for:
- Landing pages where SEO and first-paint speed matter
- Content-heavy pages (blogs, docs) — this documentation site uses
serverView - Embedded widgets where the host page needs the content in the initial HTML
Don't use it for:
- Dashboard apps behind authentication (no SEO benefit)
- Highly interactive apps where the initial render is immediately replaced by user actions
- Prototypes and internal tools (added complexity for no user benefit)
This Site Uses It
The CellUI documentation site at cellui.pages.dev is built with serverView(). Every page — the landing page, the docs, the playground shell — is rendered server-side by CellUI's own SSR function. Client-side CellUI then adds interactivity such as search, scroll spy, and the live demo. The dedicated SSR hydration example shows marker-based hydration for interactive server-rendered components.