Skip to content

Universal rendering ​

The browser, server, hydration, streaming, and static entry points share the same public template and component model. Request-local Router, Store, application, and effect ownership prevents cross-request state reuse.

First SSR application ​

Start with the request boundary before reading marker and style transport details. The server creates request-local data and an application; it sends the HTML and state carrier; the browser creates the matching application and hydrates the retained #app DOM.

ts
import { createApp, html } from '@gluonjs/core';
import { renderRequest } from '@gluonjs/ssr';
import { hydrateApplication } from '@gluonjs/ssr/hydration';

interface User { readonly name: string; }

async function loadUser(url: string, signal: AbortSignal): Promise<User> {
  const response = await fetch(`/api/user?from=${encodeURIComponent(url)}`, { signal });
  return response.json() as Promise<User>;
}

function readStateCarrier(): { readonly server: unknown; readonly client: unknown } {
  const carrier = document.querySelector('[data-gluon-state]');
  return carrier?.textContent
    ? JSON.parse(carrier.textContent) as { readonly server: unknown; readonly client: unknown }
    : { server: undefined, client: undefined };
}

// server-only — one request at a time
export async function handleRequest(url: string) {
  return renderRequest<{ readonly user: User }>({
    url,
    load: async ({ signal }) => ({ user: await loadUser(url, signal) }),
    createApp: ({ data }) => createApp(() => html`
      <main id="app"><h1>Hello ${data.user.name}</h1></main>
    `),
  });
}

// browser-only — after the server HTML is in #app
const app = createApp(() => html`<main id="app"><h1>Hello</h1></main>`);
await hydrateApplication(app, document.querySelector('#app')!, {
  state: readStateCarrier(),
  recovery: 'throw',
});

// Shared application code may define templates/components, but must not read
// window, document, localStorage, or a process-global live store during SSR.

The labels in the example are ownership boundaries:

LabelRule
sharedTemplates and pure component definitions may be imported by both sides.
server-onlyRequest loading, renderRequest(), and response assembly run once per request.
browser-onlyhydrateApplication(), window, and document run after browser boot.
per-requestRouter, Store, app, effects, and loaded data are created inside handleRequest().
application-globalImmutable constants and pure functions are safe; live request state is not.

Hydration is not a second server render. It compares the browser DOM with the same application output, restores the transported state, then mounts the live client runtime. Use recovery: 'throw' while diagnosing mismatches so the first wrong boundary remains visible.

Render safe HTML and state ​

ts
import { html } from '@gluonjs/core';
import { renderToString, serializeSsrState } from '@gluonjs/ssr';

const state = { route: '/products/orbit-lamp', bag: [] };
const rendered = await renderToString(html`<main><h1>Orbit Lamp</h1></main>`);

export const responseBody = `<!doctype html>
<main id="app">${rendered}</main>
<script type="application/json" data-gluon-state>${serializeSsrState(state)}</script>`;

renderToString() escapes ordinary child and attribute values. State transport accepts finite JSON values and escapes HTML-significant characters. Dynamic raw HTML and unsafe URLs require visibly unsafe APIs and reviewed inputs.

Hydration and static output ​

The server emits deterministic hydration markers and validated style carriers. The browser restores Router and Store snapshots before hydrateApplication(). Static generation prerenders explicit public URLs and records dynamic fallbacks without forking application modules.

Tailwind inside Shadow DOM ​

Tailwind's document stylesheet does not cross a Shadow DOM boundary. Use the optional @gluonjs/vite/tailwind entry with one Tailwind CSS import and pass the generated Vite asset manifest to SSR. The manifest's shadowStyles entries produce one compact stylesheet link per Declarative Shadow DOM root, rather than duplicating Tailwind's generated CSS in every template. During hydration, pass the same entries to hydrateApplication() or hydrateElement(); Gluon loads each asset once per document, verifies its digest, then adopts the same constructed sheet into all participating roots.

Read the hydration guide and deployment guide for the complete handoff.

Troubleshoot from the symptom ​

SymptomLikely causeDiagnostic/checkMinimal fix
Browser API throws during SSRShared code read window or documentRun the server entry alone and inspect the stackMove the read to browser-only code or a connection hook.
Hydration mismatchServer and browser inputs differUse recovery: 'throw' and inspect the first mismatch pathMake data/request inputs deterministic and transport state explicitly.
Async boundary stays in fallbackServer boundary was never declared/resolvedCheck Suspense and prepareForHydration() usagePut the loader in Suspense({ source }); choose blocking or progressive policy.
Duplicate server/client fetchBrowser starts a request instead of consuming handoffInspect the state carrier and client boot orderPrefetch per request or hydrate the resolved boundary before mounting.
Shadow DOM is unstyledDocument CSS does not cross a shadow rootInspect style manifest and adopted sheetsUse component styles and the documented Shadow DOM asset handoff.

For the async decision itself, continue with async component data and SSR.

Version-matched documentation. Examples are checked against the public package contracts.