Skip to content

SSR with Data Loading

This guide builds on Server Rendering and Client Hydration. Read those first.


Basic SSR renders static content. But when you need data from an API or database, you need:

  1. A way to load data into your app that works both on the client and server (load())
  2. A server render function that also waits for the data when run on the server (renderToStringAsync)
  3. A way to pass that data to the client to provide back into your app (hydrationData), which completes the cycle by providing it back into the respective load() instance (and thus hydrating them) 🔄

Rimitive provides a module called load() for this. You wrap the parts of your UI that need async data, Rimitive handles fetching on the server, serializes the results, and hydrates the client without refetching.


The first step is to setup the shared service. Update your shared service to include createLoaderModule:

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 = {
hydrationData?: Record<string, unknown>;
};
export function createService(
adapter: Adapter<DOMAdapterConfig>,
options?: ServiceOptions
) {
return compose(
SignalModule,
ComputedModule,
EffectModule,
createElModule(adapter),
createMatchModule(adapter),
// 👇 Add this
createLoaderModule({
initialData: options?.hydrationData,
})
);
}
export type Service = ReturnType<typeof createService>;

The initialData option receives all the data fetched during SSR as a key/value map. When each load() runs on the client, it checks this map first—if it finds data for its ID, it skips it’s initial fetch-and-render, and uses the cached value instead. This prevents hydration mismatches and duplicate requests.


load() takes three arguments:

ArgumentPurpose
idUnique string identifier. The client uses this to find matching data from SSR.
fetcherAsync function that returns your data. Runs on server, skipped on client if data exists.
renderFunction that receives loading state and returns UI.
const UserProfile = ({ loader, match, el }: Service) => () => {
return loader.load(
'user-profile',
() => fetch('/api/user').then(r => r.json()),
(state) =>
match(state.status, (status) => {
switch (status) {
case 'pending':
return el('div')('Loading...');
case 'error':
return el('div')(`Error: ${state.error()}`);
case 'ready':
const user = state.data()!;
return el('div')(
el('h1')(user.name),
el('p')(user.email)
);
}
})
);
};
PropertyTypeDescription
state.status'pending' | 'ready' | 'error'Current loading state
state.data()T | undefinedYour data (it’s a signal—call it)
state.error()unknown | undefinedThe error, if any

With async, we use renderToStringAsync instead of calling serialize() directly. It waits for all load() calls to resolve before returning HTML.

server.ts
import { createServer } from 'node:http';
import {
createParse5Adapter,
renderToStringAsync,
safeJsonStringify,
} from '@rimitive/ssr/server';
import { createService } from './service.js';
import { App } from './App.js';
const server = createServer(async (req, res) => {
const { adapter, serialize, insertFragmentMarkers } = createParse5Adapter();
const service = createService(adapter);
// Render and wait for all load() boundaries
const html = await renderToStringAsync(App(service), {
svc: service,
mount: (spec) => spec.create(service),
serialize,
insertFragmentMarkers,
});
// Collect fetched data from all the loader calls that occurred during render
// (collected by the loader)
const loaderData = service.loader.getData();
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<!DOCTYPE html>
<html>
<head>
<script>window.__RIMITIVE_DATA__ = ${safeJsonStringify(loaderData)}</script>
</head>
<body>
<div class="app">${html}</div>
<script src="/client.js"></script>
</body>
</html>`);
});
server.listen(3000);

Pass the embedded data (window.__RIMITIVE_DATA__, or whatever you decide to call it) to your service as hydrationData:

client.ts
import { createDOMAdapter } from '@rimitive/view/adapters/dom';
import {
createDOMHydrationAdapter,
createHydrationAdapter,
} from '@rimitive/ssr/client';
import { createService } from './service.js';
import { App } from './App.js';
const hydrationAdapter = createHydrationAdapter(
createDOMHydrationAdapter(document.querySelector('.app')!),
createDOMAdapter()
);
// 👇 Pass the SSR data to the service
const ssrData = window.__RIMITIVE_DATA__;
// Remember from step 1? Here's where we pass the data 👇
const service = createService(hydrationAdapter, { hydrationData: ssrData });
App(service).create(service);
hydrationAdapter.switchToFallback();

When load('user-profile', ...) runs during hydration, it finds the corresponding 'user-profile' id in hydrationData and uses that data immediately—no fetch, no loading state, no mismatch.


If a fetcher throws, load() catches it and renders the respective error state:

loader.load(
'might-fail',
async () => {
const res = await fetch('/api/flaky');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
(state) =>
match(state.status, (status) => {
switch (status) {
case 'pending':
return el('div')('Loading...');
case 'error':
return el('div').class('error')(`Failed: ${state.error()}`);
case 'ready':
return DataView(state.data()!);
}
})
);

The error is serialized too—the client receives the error UI already rendered.

For errors outside load() boundaries, wrap renderToStringAsync:

try {
const html = await renderToStringAsync(App(service), options);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);
} catch (err) {
console.error('SSR failed:', err);
res.writeHead(500);
res.end('Internal Server Error');
}

With renderToStringAsync, the server waits for all data before responding. That’s great for pages where you want to render everything up-front for good SEO and fast load times. For pages with slow or independent data sources, you might want to send HTML progressively:

  • Streaming SSR — Send the shell immediately, stream data as it loads