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-N attributes (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 to count via signal.bindNode()
  • The data-cellui-on-1 attribute is found, and the original onclick handler 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 streamingserverView generates the full HTML string synchronously. No progressive rendering.
  • No selective hydrationhydrate() 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.