SSR with Data Loading
This guide builds on Server Rendering and Client Hydration. Read those first.
What’s Different?
Section titled “What’s Different?”Basic SSR renders static content. But when you need data from an API or database, you need:
- A way to load data into your app that works both on the client and server (
load()) - A server render function that also waits for the data when run on the server (
renderToStringAsync) - 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 respectiveload()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.
1. Service Setup
Section titled “1. Service Setup”The first step is to setup the shared service. Update your shared service to include createLoaderModule:
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.
Using load()
Section titled “Using load()”load() takes three arguments:
| Argument | Purpose |
|---|---|
id | Unique string identifier. The client uses this to find matching data from SSR. |
fetcher | Async function that returns your data. Runs on server, skipped on client if data exists. |
render | Function 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) ); } }) );};The State Object
Section titled “The State Object”| Property | Type | Description |
|---|---|---|
state.status | 'pending' | 'ready' | 'error' | Current loading state |
state.data() | T | undefined | Your data (it’s a signal—call it) |
state.error() | unknown | undefined | The error, if any |
2. Server Setup
Section titled “2. Server Setup”With async, we use renderToStringAsync instead of calling serialize() directly. It waits for all load() calls to resolve before returning HTML.
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);3. Client Setup
Section titled “3. Client Setup”Pass the embedded data (window.__RIMITIVE_DATA__, or whatever you decide to call it) to your service as hydrationData:
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 serviceconst 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.
Error Handling
Section titled “Error Handling”Errors in load() Boundaries
Section titled “Errors in load() Boundaries”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.
Server-Level Errors
Section titled “Server-Level Errors”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');}Next Steps
Section titled “Next Steps”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