Skip to content

Server-Side Rendering (SSR)

PreffX renders your components on the server to an HTML string, then hydrates the same tree on the client. The server emits the markup plus a per-root preload script containing resolved async data — the client reads that script and feeds the values into the tree instead of re-fetching.

SSR utilities are imported from preffx/server.

createRoot on the server

The server exports its own createRoot, which mirrors the client one but returns a renderToString method instead of mount.

ts
import { createRoot } from 'preffx/server';
import { App } from './App';

const server = createRoot();

const { preload, serialize } = server.renderToString(App, {
    props: { name: 'PreffX' }
});

await preload();          // resolve every async resource
const html = serialize(); // markup + preload data script

A fresh root is typically created per request, because the app may depend on the request URL (routing, lang) or request-scoped context:

ts
import { createRoot } from 'preffx/server';

export async function render(url: URL) {
    const root = createRoot({
        defaultURL: url,
        defaultLang: 'en',
        context: { tenant: 'acme' }
    });

    const { preload, serialize } = root.renderToString(App, {});
    await preload();

    return { appHtml: serialize() };
}

API

createRoot(config?)

Accepts the same root options as the client root, so configuration stays in sync:

OptionTypeDescription
defaultURLstring | URLInitial URL for routing
defaultLangstringInitial language for i18n
prefixstringPer-root prefix (must match the client root)
contextobjectRequest-scoped context shared with the tree

Returns an object with a single method:

renderToString(component, config?)

  • component — the root component (PC or APC).
  • config — optional, with:
    • props — props for the root component.
    • serializer — custom function to serialize the preloaded data (defaults to JSON.stringify).

Returns { preload, serialize }:

MethodSignatureDescription
preloadpreload(timeout?)Resolves every registered async resource. An optional timeout makes a slow resource "best-effort" — the page still renders and the client refetches.
serializeserialize(): stringEmits the HTML string (markup + per-root preload data script).

Resources on the server

Inside a component, resource behaves transparently on the server: instead of running an effect, it registers its fetcher for preload(). Reuse the same component for both server and client — no branch needed.

tsx
import type { PC } from 'preffx';

export const UserCard: PC = (_props, { resource, computed }) => {
    const [user] = resource(async () => {
        const res = await fetch('/api/user');
        return res.json();
    });

    const display = computed(() => user.state.value?.name ?? 'loading');

    return (
        <article className="user">
            <h1>{display}</h1>
        </article>
    );
};
  • Before preload() runs, user.state.value is null and pending is true, so the fallback ('loading') renders.
  • After preload(), the resolved value is embedded in the markup, and serialize() also writes it to the preload data script.
  • On the client during hydration, the resource reads the preloaded value and skips the fetch, keeping pending false.

Hydration on the client

The client createRoot().mount() detects the preload data script emitted by the server, feeds it into the tree, and hydrates the existing DOM instead of recreating it — no extra call needed.

tsx
import { createRoot } from 'preffx';
import { App } from './App';

createRoot().mount(App, {
    node: document.getElementById('app')!
});

Requirements for a successful hydration:

  • The client createRoot options (prefix, defaultLang, defaultURL, context) must match the server ones.
  • The rendered markup must be inserted exactly where the client expects it (the container referenced by mount).

Example: full request flow

tsx
// entry-server.tsx
import { createRoot } from 'preffx/server';
import { App } from './App';

export async function render(url: URL) {
    const server = createRoot({ defaultURL: url });
    const { preload, serialize } = server.renderToString(App, {});
    await preload();
    return { appHtml: serialize() };
}
tsx
// entry-client.tsx
import { createRoot } from 'preffx';
import { App } from './App';

createRoot().mount(App, { node: document.getElementById('app')! });

The Node server renders appHtml into the HTML shell (e.g. index.html) and sends it to the browser; the client script then hydrates the same tree.

Released under the Apache-2.0 License.