Server-Side Rendering (SSR)
Sagun supports server-side rendering with data fetching and hydration.
Overview
SSR in Sagun works by:
- Marking operations with
ssr: true - Rendering on server, collecting operation results
- Serializing state and SSR hash to client
- Hydrating on client, skipping already-fetched operations
Setup
Mark SSR Operations
class ProductService extends Service {
toString() { return 'ProductService'; }
@operation({ ssr: true }) // Enable SSR
*fetchProducts() {
return yield* call(api.getProducts);
}
@operation // Client-only
*trackView(productId: string) {
yield* call(analytics.track, 'view', productId);
}
}
Subscribe in Component
function ProductPage() {
const { service } = useServiceConsumer(ProductService);
const { operationId } = useSaga({
id: 'products',
onLoad: service.fetchProducts,
});
return (
<Suspense fallback={<Spinner />}>
<Operation operationId={operationId}>
{({ result }) => <ProductList products={result} />}
</Operation>
</Suspense>
);
}
React 18+ Implementation
Server
// server.ts
import { createDeferred, Root, OperationService, ComponentLifecycleService, useOperation } from '@iiiristram/sagun';
import { renderToPipeableStream } from 'react-dom/server';
import { PassThrough } from 'stream';
import { Provider } from 'react-redux';
import createSagaMiddleware from 'redux-saga';
import { applyMiddleware, createStore } from 'redux';
import { call } from 'typed-redux-saga';
async function render(req, res) {
const sagaMiddleware = createSagaMiddleware();
const store = applyMiddleware(sagaMiddleware)(createStore)(asyncOperationsReducer);
// Important: Initialize hash as empty object for SSR
const operationService = new OperationService({ hash: {} });
const componentLifecycleService = new ComponentLifecycleService(operationService);
const task = sagaMiddleware.run(function* () {
yield* call(operationService.run);
yield* call(componentLifecycleService.run);
});
useOperation.setPath(state => state);
let html = '';
const defer = createDeferred();
const stream = renderToPipeableStream(
<Root
operationService={operationService}
componentLifecycleService={componentLifecycleService}
>
<Provider store={store}>
<App />
</Provider>
</Root>,
{
onAllReady() {
const s = new PassThrough();
stream.pipe(s);
s.on('data', chunk => {
html += chunk;
});
s.on('end', () => {
defer.resolve();
});
},
onError(err) {
console.error(err);
defer.reject(err);
},
}
);
await defer.promise;
// Cleanup sagas
task.cancel();
await task.toPromise();
// Send response with state and hash
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>App</title>
</head>
<body>
<script>
window.__STATE_FROM_SERVER__ = ${JSON.stringify(store.getState())};
window.__SSR_CONTEXT__ = ${JSON.stringify(operationService.getHash())};
</script>
<div id="app">${html}</div>
<script src="/bundle.js"></script>
</body>
</html>
`);
}
Client
// client.ts
import { Root, OperationService, ComponentLifecycleService, useOperation, asyncOperationsReducer } from '@iiiristram/sagun';
import { hydrateRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import createSagaMiddleware from 'redux-saga';
import { applyMiddleware, createStore } from 'redux';
import { call } from 'typed-redux-saga';
const sagaMiddleware = createSagaMiddleware();
// Initialize store with server state
const store = applyMiddleware(sagaMiddleware)(createStore)(
asyncOperationsReducer,
window.__STATE_FROM_SERVER__
);
// Pass SSR hash - operations with same args will be skipped
const operationService = new OperationService({
hash: window.__SSR_CONTEXT__
});
const componentLifecycleService = new ComponentLifecycleService(operationService);
sagaMiddleware.run(function* () {
yield* call(operationService.run);
yield* call(componentLifecycleService.run);
});
useOperation.setPath(state => state);
hydrateRoot(
document.getElementById('app'),
<Root
operationService={operationService}
componentLifecycleService={componentLifecycleService}
>
<Provider store={store}>
<App />
</Provider>
</Root>
);
React 17 and Below
For React 17, use renderToStringAsync from Sagun:
// server.ts
import { renderToStringAsync } from '@iiiristram/sagun/server';
async function render(req, res) {
// ... setup same as above ...
// Use renderToStringAsync instead of renderToPipeableStream
const html = await renderToStringAsync(
<Root
operationService={operationService}
componentLifecycleService={componentLifecycleService}
>
<Provider store={store}>
<App />
</Provider>
</Root>
);
// ... send response same as above ...
}
Disabling SSR for Subtrees
Use DisableSsrContext to prevent SSR for specific components:
import { DisableSsrContext } from '@iiiristram/sagun';
function App() {
return (
<div>
{/* SSR enabled */}
<Header />
<ProductList />
{/* SSR disabled for this subtree */}
<DisableSsrContext.Provider value={true}>
<InteractiveChat />
<LiveNotifications />
</DisableSsrContext.Provider>
</div>
);
}
SSR Best Practices
1. Only Mark Data-Fetching Operations
class ProductService extends Service {
// ✅ Data fetching - enable SSR
@operation({ ssr: true })
*fetchProducts() {
return yield* call(api.getProducts);
}
// ❌ User interaction - keep client-only
@operation
*addToCart(productId: string) {
return yield* call(api.addToCart, productId);
}
}
2. Handle SSR-Specific Logic
@operation({ ssr: true })
*fetchInitialData() {
// Different behavior on server vs client
if (isNodeEnv()) {
// Server: fetch from internal service
return yield* call(internalApi.getData);
} else {
// Client: fetch from public API
return yield* call(publicApi.getData);
}
}
3. Avoid Side Effects in SSR Operations
// ❌ Bad - side effects in SSR operation
@operation({ ssr: true })
*fetchUser() {
const user = yield* call(api.getUser);
yield* call(analytics.track, 'user_loaded'); // Side effect!
return user;
}
// ✅ Good - separate concerns
@operation({ ssr: true })
*fetchUser() {
return yield* call(api.getUser);
}
@operation // Client-only
*trackUserLoaded() {
yield* call(analytics.track, 'user_loaded');
}
4. Test SSR Separately
describe('ProductService SSR', () => {
it('should fetch products on server', async () => {
const operationService = new OperationService({ hash: {} });
// ... render on "server" ...
const hash = operationService.getHash();
expect(hash['PRODUCT_SERVICE_FETCH_PRODUCTS']).toBeDefined();
});
it('should skip fetch on client with hash', async () => {
const hash = {
'PRODUCT_SERVICE_FETCH_PRODUCTS': {
args: [],
result: mockProducts
}
};
const operationService = new OperationService({ hash });
// ... hydrate on "client" ...
// API should not be called
expect(api.getProducts).not.toHaveBeenCalled();
});
});