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.
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 scriptA fresh root is typically created per request, because the app may depend on the request URL (routing, lang) or request-scoped context:
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:
| Option | Type | Description |
|---|---|---|
defaultURL | string | URL | Initial URL for routing |
defaultLang | string | Initial language for i18n |
prefix | string | Per-root prefix (must match the client root) |
context | object | Request-scoped context shared with the tree |
Returns an object with a single method:
renderToString(component, config?)
component— the root component (PCorAPC).config— optional, with:props— props for the root component.serializer— custom function to serialize the preloaded data (defaults toJSON.stringify).
Returns { preload, serialize }:
| Method | Signature | Description |
|---|---|---|
preload | preload(timeout?) | Resolves every registered async resource. An optional timeout makes a slow resource "best-effort" — the page still renders and the client refetches. |
serialize | serialize(): string | Emits 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.
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.valueisnullandpendingistrue, so the fallback ('loading') renders. - After
preload(), the resolved value is embedded in the markup, andserialize()also writes it to the preload data script. - On the client during hydration, the resource reads the preloaded value and skips the fetch, keeping
pendingfalse.
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.
import { createRoot } from 'preffx';
import { App } from './App';
createRoot().mount(App, {
node: document.getElementById('app')!
});Requirements for a successful hydration:
- The client
createRootoptions (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
// 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() };
}// 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.