Skip to content

Server Rendering

Server-side rendering in Rimitive renders your components to HTML on the server, waits for async data to load, then sends the complete page to the browser. The client hydrates the existing DOM instead of recreating it.

Unlike many frameworks, Rimitive effects are synchronous and run on the server too. There’s no special “client-only” mode — your reactive code works the same way in both environments. This makes SSR predictable: signals update, effects run, DOM changes, all synchronously.


You need three pieces:

  1. A server adapter (uses linkedom instead of the real DOM)
  2. A render function that awaits async boundaries
  3. A client that hydrates the SSR HTML
import { createDOMServerAdapter } from '@rimitive/ssr/server';
const { adapter, serialize, insertFragmentMarkers } = createDOMServerAdapter();

The server adapter creates elements using linkedom (a lightweight DOM implementation). serialize converts elements to HTML strings. insertFragmentMarkers adds comment markers for hydration.

Your service factory takes an adapter, so both server and client can use the same components:

service.ts
import { compose } from '@rimitive/core';
import { SignalModule, ComputedModule, EffectModule } from '@rimitive/signals/extend';
import { createElModule } from '@rimitive/view/el';
import { createMatchModule } from '@rimitive/view/match';
import { createLoaderModule } from '@rimitive/view/load';
import type { Adapter } from '@rimitive/view/types';
import type { DOMAdapterConfig } from '@rimitive/view/adapters/dom';
export type ServiceOptions = {
loaderData?: Record<string, unknown>;
};
export function createService(
adapter: Adapter<DOMAdapterConfig>,
options?: ServiceOptions
) {
return compose(
SignalModule,
ComputedModule,
EffectModule,
createElModule(adapter),
createMatchModule(adapter),
createLoaderModule({
initialData: options?.loaderData,
})
);
}
export type Service = ReturnType<typeof createService>;

load() creates async boundaries that work with SSR. It takes three arguments:

  1. id — A unique string for data lookup during hydration
  2. fetcher — An async function that returns data
  3. renderer — A function that receives state and returns UI
import type { LoadState, LoadStatus } from '@rimitive/view/load';
type UserData = { name: string; email: string };
const UserProfile = (svc: Service) => {
const { loader, match, el } = svc;
return loader.load(
'user-profile', // ID for hydration
() => fetch('/api/user').then(r => r.json()),
(state: LoadState<UserData>) =>
match(state.status, (status: LoadStatus) => {
switch (status) {
case 'pending':
return el('div')('Loading...');
case 'error':
return el('div')(`Error: ${state.error()}`);
case 'ready':
return el('div')(
el('h1')(state.data()!.name),
el('p')(state.data()!.email)
);
}
})
);
};

The state object has reactive properties:

  • state.status'pending' | 'ready' | 'error'
  • state.data() — The loaded data (undefined until ready)
  • state.error() — The error (undefined unless error)

Always handle errors in your renderer. The error state catches exceptions from your fetcher:

loader.load(
'stats',
async () => {
const res = await fetch('/api/stats');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return res.json();
},
(state) =>
match(state.status, (status) => {
switch (status) {
case 'pending':
return Loading();
case 'error':
return ErrorDisplay(state.error());
case 'ready':
return StatsView(state.data()!);
}
})
)

Use renderToStringAsync to render your app and wait for all load() boundaries:

server.ts
import { createServer } from 'node:http';
import {
createDOMServerAdapter,
renderToStringAsync,
} from '@rimitive/ssr/server';
import type { RefSpec } from '@rimitive/view/types';
import { createService } from './service.js';
import { App } from './App.js';
const server = createServer(async (req, res) => {
// Create per-request adapter and service
const { adapter, serialize, insertFragmentMarkers } = createDOMServerAdapter();
const service = createService(adapter);
// Create the app RefSpec
const appSpec = App(service);
// Render to string, awaiting all load() boundaries
const html = await renderToStringAsync(appSpec, {
svc: service,
mount: (spec: RefSpec<unknown>) => spec.create(service),
serialize,
insertFragmentMarkers,
});
// Get loader data for hydration
const loaderData = service.loader.getData();
// Send HTML with embedded data
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<script>window.__LATTICE_DATA__ = ${JSON.stringify(loaderData)}</script>
</head>
<body>
<div class="app">${html}</div>
<script src="/client.js"></script>
</body>
</html>
`);
});
server.listen(3000);

Key points:

  • Create a fresh adapter and service per request (no shared state)
  • renderToStringAsync awaits all load() boundaries before returning
  • service.loader.getData() collects all resolved data
  • Embed the data in a script tag for client hydration

The client hydrates the existing DOM instead of creating new elements:

client.ts
import { createClientAdapter } from '@rimitive/ssr/client';
import { createService } from './service.js';
import { App } from './App.js';
// Create hydration adapter for the SSR root element
const adapter = createClientAdapter(document.querySelector('.app')!);
// Get loader data from SSR
const loaderData = window.__LATTICE_DATA__;
// Create service with hydrating adapter and loader data
const service = createService(adapter, { loaderData });
// Hydrate - walks existing DOM, wires up reactivity
App(service).create(service);
// Switch to normal DOM mode for future updates
adapter.activate();

What happens:

  1. createClientAdapter creates an adapter that walks existing DOM
  2. loaderData provides pre-fetched data so load() doesn’t refetch
  3. .create(service) hydrates the app, connecting signals to existing elements
  4. adapter.activate() switches to normal DOM mode for subsequent updates

Here’s a minimal but complete SSR setup:

service.ts
import { compose } from '@rimitive/core';
import { SignalModule, ComputedModule, EffectModule } from '@rimitive/signals/extend';
import { createElModule } from '@rimitive/view/el';
import { createMatchModule } from '@rimitive/view/match';
import { createLoaderModule } from '@rimitive/view/load';
import type { Adapter } from '@rimitive/view/types';
import type { DOMAdapterConfig } from '@rimitive/view/adapters/dom';
export type ServiceOptions = {
loaderData?: Record<string, unknown>;
};
export function createService(
adapter: Adapter<DOMAdapterConfig>,
options?: ServiceOptions
) {
return compose(
SignalModule,
ComputedModule,
EffectModule,
createElModule(adapter),
createMatchModule(adapter),
createLoaderModule({ initialData: options?.loaderData })
);
}
export type Service = ReturnType<typeof createService>;
App.ts
import type { LoadState, LoadStatus } from '@rimitive/view/load';
import type { Service } from './service.js';
type Stats = { users: number; views: number };
async function fetchStats(): Promise<Stats> {
const res = await fetch('https://api.example.com/stats');
if (!res.ok) throw new Error('Failed to fetch stats');
return res.json();
}
export const App = (svc: Service) => {
const { loader, match, el } = svc;
return el('div').props({ className: 'app' })(
el('h1')('My App'),
loader.load(
'stats',
fetchStats,
(state: LoadState<Stats>) =>
match(state.status, (status: LoadStatus) => {
switch (status) {
case 'pending':
return el('div').props({ className: 'loading' })('Loading stats...');
case 'error':
return el('div').props({ className: 'error' })(
'Failed to load stats: ',
String(state.error())
);
case 'ready': {
const data = state.data()!;
return el('div').props({ className: 'stats' })(
el('p')(`Users: ${data.users}`),
el('p')(`Views: ${data.views}`)
);
}
}
})
)
);
};
server.ts
import { createServer } from 'node:http';
import {
createDOMServerAdapter,
renderToStringAsync,
} from '@rimitive/ssr/server';
import type { RefSpec } from '@rimitive/view/types';
import { createService } from './service.js';
import { App } from './App.js';
const server = createServer(async (req, res) => {
const { adapter, serialize, insertFragmentMarkers } = createDOMServerAdapter();
const service = createService(adapter);
try {
const html = await renderToStringAsync(App(service), {
svc: service,
mount: (spec: RefSpec<unknown>) => spec.create(service),
serialize,
insertFragmentMarkers,
});
const loaderData = service.loader.getData();
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<!DOCTYPE html>
<html>
<head>
<title>My App</title>
<script>window.__LATTICE_DATA__=${JSON.stringify(loaderData)}</script>
</head>
<body>
<div class="app">${html}</div>
<script src="/client.js"></script>
</body>
</html>`);
} catch (err) {
console.error('SSR Error:', err);
res.writeHead(500);
res.end('Server Error');
}
});
server.listen(3000);
client.ts
import { createClientAdapter } from '@rimitive/ssr/client';
import { createService } from './service.js';
import { App } from './App.js';
const adapter = createClientAdapter(document.querySelector('.app')!);
const service = createService(adapter, {
loaderData: window.__LATTICE_DATA__,
});
App(service).create(service);
adapter.activate();

Server-side errors are caught and rendered as the error state. The client receives the error UI, and the error is preserved in the loader data.

For server-level errors (outside of load() boundaries), wrap renderToStringAsync in a try/catch:

try {
const html = await renderToStringAsync(appSpec, options);
// ... send HTML
} catch (err) {
console.error('SSR failed:', err);
res.writeHead(500);
res.end('Internal Server Error');
}

Since effects and refs run on the server, code that relies on browser APIs needs guards. The server uses linkedom, which implements core DOM operations but not browser-specific features like focus(), scrollIntoView(), or getBoundingClientRect().

Guard with environment check:

el('input').ref((el) => {
if (typeof window === 'undefined') return;
el.focus();
})()

Use optional chaining for methods that might not exist:

el('div').ref((el) => {
el.scrollIntoView?.({ behavior: 'smooth' });
})()

Feature detection:

el('div').ref((el) => {
if ('animate' in el) {
el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300 });
}
})()
  • Event handlers via on() — The server adapter skips these automatically
  • Event handlers via props (onclick, oninput) — Also skipped on server
  • Basic DOM propertiesclassName, textContent, id, etc. work fine
  • focus(), blur()
  • scrollIntoView(), scrollTo()
  • getBoundingClientRect(), getClientRects()
  • animate()
  • Browser globals: window, document, localStorage, navigator

This covers basic SSR where the server waits for all data before sending HTML. For pages with slow data sources, you might want to send HTML immediately and stream data as it loads. That’s covered in Streaming SSR.