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.