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.
The Basic Setup
Section titled “The Basic Setup”You need three pieces:
- A server adapter (uses linkedom instead of the real DOM)
- A render function that awaits async boundaries
- A client that hydrates the SSR HTML
Server Adapter
Section titled “Server Adapter”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.
Creating a Service
Section titled “Creating a Service”Your service factory takes an adapter, so both server and client can use the same components:
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>;Loading Data with load()
Section titled “Loading Data with load()”load() creates async boundaries that work with SSR. It takes three arguments:
- id — A unique string for data lookup during hydration
- fetcher — An async function that returns data
- 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)
Error Handling
Section titled “Error Handling”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()!); } }))Rendering on the Server
Section titled “Rendering on the Server”Use renderToStringAsync to render your app and wait for all load() boundaries:
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)
renderToStringAsyncawaits allload()boundaries before returningservice.loader.getData()collects all resolved data- Embed the data in a script tag for client hydration
Hydrating on the Client
Section titled “Hydrating on the Client”The client hydrates the existing DOM instead of creating new elements:
import { createClientAdapter } from '@rimitive/ssr/client';import { createService } from './service.js';import { App } from './App.js';
// Create hydration adapter for the SSR root elementconst adapter = createClientAdapter(document.querySelector('.app')!);
// Get loader data from SSRconst loaderData = window.__LATTICE_DATA__;
// Create service with hydrating adapter and loader dataconst service = createService(adapter, { loaderData });
// Hydrate - walks existing DOM, wires up reactivityApp(service).create(service);
// Switch to normal DOM mode for future updatesadapter.activate();What happens:
createClientAdaptercreates an adapter that walks existing DOMloaderDataprovides pre-fetched data soload()doesn’t refetch.create(service)hydrates the app, connecting signals to existing elementsadapter.activate()switches to normal DOM mode for subsequent updates
A Complete Example
Section titled “A Complete Example”Here’s a minimal but complete SSR setup:
Shared Service
Section titled “Shared Service”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 Component
Section titled “App Component”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
Section titled “Server”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
Section titled “Client”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();When Data Fetching Fails
Section titled “When Data Fetching Fails”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');}Browser-Only Code in Refs
Section titled “Browser-Only Code in Refs”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().
Common Patterns
Section titled “Common Patterns”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 }); }})()What Works Without Guards
Section titled “What Works Without Guards”- Event handlers via
on()— The server adapter skips these automatically - Event handlers via props (
onclick,oninput) — Also skipped on server - Basic DOM properties —
className,textContent,id, etc. work fine
What Needs Guards
Section titled “What Needs Guards”focus(),blur()scrollIntoView(),scrollTo()getBoundingClientRect(),getClientRects()animate()- Browser globals:
window,document,localStorage,navigator
Next Steps
Section titled “Next Steps”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.