Server Side Rendering
Server Side Rendering (SSR) can improve the first-load performance of your application. Reactive Data Client takes this one step further by pre-populating the data store. Unlike other SSR methodologies, Reactive Data Client becomes interactive the moment the page is visible, making data mutations instantaneous. Additionally there is no need for additional data fetches that increase server load and slow client hydration, potentially causing application stutters.
Incremental streamed hydration
The store is not a second document that waits for the page. It is the same stream, in generations:
- The shell carries an inert baseline. First paint is not delayed for Data Client.
- Each later committed server revision emits a StateDelta. The client folds queued pieces into the hydration snapshot and dispatches
HYDRATEinto the live store from the receiver layout effect. - Several islands that finish in one flush share one delta. A nested child may become readable before its parent. A later island may write an entity already present from an earlier delta; the three-way merge keeps slots the client already changed.
- If that fold has already happened,
useSuspense()hits. If RSC starts the island first, a miss fetches like any client render. - After the island commits,
useLive()/useSubscription()dispatchSUBSCRIBEso WebSocket or polling may start. Subscription is not proof the server delta arrived.
Next.js App Router emits this protocol. Pages Router (@data-client/ssr/nextjs) and generic @data-client/react/ssr (Express, Anansi, renderToPipeableStream) remain a one-shot document snapshot.
useServerInsertedHTML() writes into the HTML stream. It does not order RSC. A Client Component may start before its delta script runs and before the receiver layout effect. Script-before-HTML is insertion order, not a zero-refetch guarantee.
MarketPage
├── Shell provider, chrome — no Data Client read
├── Watchlist useLive(getTickers)
├── SymbolHeader useLive(getSymbolInfo, { symbol: 'BTC' })
└── Market Suspense (late)
├── Book useLive(getOrderBook, { symbol: 'BTC' })
└── Tape nested Suspense
└── Trades useLive(getTrades, { symbol: 'BTC' })
The figures below are one concept each: an overview with black boxes, then a zoom that opens that box. They describe the intended client clock (per-key waiters and fold-on-script). This release ships the wire (baseline + StateDelta + HYDRATE) and the receiver layout-effect fold.
Thick-border nodes on the overview are black boxes. Each zoom opens one box.
| Piece | Server revision | Endpoints in the piece | Entities | Who hydrates from it |
|---|---|---|---|---|
| Baseline | empty request store | — | — | nobody; shell only |
| First flush | one flush, two islands | getTickers, getSymbolInfo(BTC) | Ticker:BTC, Ticker:ETH, Symbol:BTC | Watchlist + SymbolHeader |
| Nested island | nested child first | getTrades(BTC) | Trade:… | Tape, before parent Book exists |
| Later overlap | disjoint parent later | getOrderBook(BTC) | Book:BTC, plus Ticker:BTC again (constructed nested ticker) | Book; live three-way merge vs tick |
Island hydrates
Opens Island hydrates. Shared path for every piece.
First flush
Opens First flush. One server revision, two islands, one StateDelta.
Nested island
Opens Nested island. Tree order is not stream order.
Later overlap
Opens Later overlap. A later disjoint piece writes an entity the first flush already put in the store.
RSC vs renderToPipeableStream
Opens RSC vs renderToPipeableStream. Two clocks, not two products.
| Wire | Host | How a piece gets on the wire | What it cannot promise |
|---|---|---|---|
| Streaming HTML + RSC | @data-client/react/nextjs | useServerInsertedHTML() writes the inert baseline, then a StateDelta script per flush, into the HTML stream | It cannot order that script against RSC. A Client Component may start before the delta that describes it has executed. |
renderToPipeableStream | @data-client/react/ssr (Express, Anansi) | Intended: a state-piece colocated in the revealing Suspense/island so React emits the delta and the dependent markup as one ordered subtree | Not shipped in this release — generic /ssr remains a one-shot snapshot. Parser/hydration order is still not a contract. |
The sequence is the intended client clock. This release does not throw per-key waiters or fold independently of the receiver layout effect.
Open questions
- Per-key waiters and fold-on-script-arrival (independent of
StreamedStateReceiver’s layout effect) remain future client-clock work. Until they land, an RSC-first miss fetches like any client render. - Incremental baseline-plus-delta for generic
renderToPipeableStream/ Anansi is not shipped. - A document-wide
DOMContentLoadedwait is an acceptable interim, not the long-term contract.
NextJS SSR
App Router
NextJS 12 includes a new way of routing in the '/app' directory. This allows further performance improvements, as well as dynamic and nested routing.
Root Layout
Place DataProvider in your root layout
import { DataProvider } from '@data-client/react/nextjs';
import { AsyncBoundary } from '@data-client/react';
export default function RootLayout({ children }) {
return (
<html>
<body>
<DataProvider>
<header>Title</header>
<AsyncBoundary>{children}</AsyncBoundary>
<footer></footer>
</DataProvider>
</body>
</html>
);
}
Async Server Components anywhere between the provider and your Client Components are fine.
Each committed server revision emits a StateDelta that folds into the hydration snapshot
and HYDRATEs the live store from the receiver layout effect. HTML insertion order is not an
RSC clock: a Client Component may start before its delta script runs, and a miss then
fetches like any client render.
export default async function UserLayout({ children, params }) {
// resolves after the shell has already been sent
const { userId } = await params;
return <section data-user={userId}>{children}</section>;
}
Props
interface NextDataProviderProps {
children: ReactNode;
managers?: () => Manager[];
nonce?: string;
Controller?: typeof Controller;
gcPolicy?: GCInterface;
devButton?: DevToolsPosition | null;
}
Controller applies on both server and client. gcPolicy applies in the browser only: a
request-scoped server store has nothing to collect.
managers
The server builds a store per request, so Managers must be created per request as well. It takes a function, called once per request on the server and once in the browser (the browser DataProvider accepts the same function; its array form is transitional):
'use client';
import { getDefaultManagers } from '@data-client/react';
import { DataProvider } from '@data-client/react/nextjs';
const managers = () => [...getDefaultManagers(), new MyManager()];
export default function Provider({ children }: { children: React.ReactNode }) {
return <DataProvider managers={managers}>{children}</DataProvider>;
}
Manager instances shared across requests would mix up users' data, so an array is rejected.
Server-side managers should not hold resources: their cleanup() is not run per request.
nonce
State is streamed in inline <script> tags. When your
Content Security Policy requires
a nonce, pass it through:
import { headers } from 'next/headers';
import { DataProvider } from '@data-client/react/nextjs';
export default async function RootLayout({ children }) {
const nonce = (await headers()).get('x-nonce') ?? undefined;
return (
<html>
<body>
<DataProvider nonce={nonce}>{children}</DataProvider>
</body>
</html>
);
}
Limitations
- Use one
DataProviderper document. Nested or sibling providers share the same streamed state. - Only the initial document is transferred. Client-side navigations and
router.refresh()fetch in the browser like any client render. - With Partial Prerendering the static shell's state is transferred; data fetched while resuming dynamic holes is fetched again in the browser.
- If the same entity is returned with different data by two requests during one render, the browser hydrates with the latest one. Boundaries rendered from the earlier value are re-rendered by React (a recoverable hydration mismatch in development). Likewise, data the browser fetched on its own during streaming fills in anything the server never sent or removed, so a boundary the server rendered without that data is re-rendered with it.
- On React 18 (including the version bundled with Next.js 13 and 14), a store update while a boundary is still hydrating - a streamed delta, a WebSocket manager, a mutation - can make React client-render that boundary. The data is still correct and nothing is refetched, but the server DOM nodes are replaced. React 19 keeps them and hydrates at a matching priority instead.
- App-owned requests that bypass
useSuspense()(for example a direct depth snapshot to resync WebSocket sequence numbers) are out of this contract.
Client Components
To keep your data fresh and performant, you can use client components and useSuspense()
'use client';
import { useSuspense } from '@data-client/react';
import { TodoResource } from '@/resources/Todo';
export default function InteractivePage({ params }: { params: { userId: number } }) {
const todos = useSuspense(TodoResource.getList, params);
return <TodoList todos={todos} />;
}
Note that this is identical to how you would write components without SSR. This makes makes the components usable across platforms.
Server Components
However, if your data never changes, you can slightly decrease the javascript bundle sent, by
using a server component. Simply await the endpoint:
import { TodoResource } from '@/resources/Todo';
export default async function StaticPage({ params }: { params: { userId: number } }) {
const todos = await TodoResource.getList(params);
return <TodoList todos={todos} />;
}
Demo
Class mangling and Entity.key
NextJS will rename classes for production builds. Due to this, it's critical to define Entity.key as its default implementation is based on the class name.
class User extends Entity {
id = '';
username = '';
static key = 'User';
}
Pages Router
Pages Router remains a one-shot document snapshot via @data-client/ssr/nextjs. It does not use
the incremental baseline-plus-delta protocol above.
With NextJS < 14, you might be using the pages router. For this we have Document and NextJS specific wrapper for App
- NPM
- Yarn
- pnpm
- esm.sh
yarn add @data-client/ssr @data-client/redux redux
npm install --save @data-client/ssr @data-client/redux redux
pnpm add @data-client/ssr @data-client/redux redux
<script type="module">
import * from 'https://esm.sh/@data-client/ssr';
import * from 'https://esm.sh/@data-client/redux';
import * from 'https://esm.sh/redux';
</script>
import { DataClientDocument } from '@data-client/ssr/nextjs';
export default DataClientDocument;
import { AppDataProvider } from '@data-client/ssr/nextjs';
import type { AppProps } from 'next/app';
export default function App({ Component, pageProps }: AppProps) {
return (
<AppDataProvider>
<Component {...pageProps} />
</AppDataProvider>
);
}
When fetching from parameters from useRouter(), you will need to add getServerSideProps to avoid NextJS setting router.query to nothing
export default function MyComponent() {
const id: string; = useRouter().query.id;
const post = useSuspense(getPost, { id });
// etc
}
export const getServerSideProps = () => ({ props: {} });
Further customizing Document
To further customize Document, simply extend from the provided document.
Make sure you use super.getInitialProps() instead of Document.getInitialProps()
or the Reactive Data Client code won't run!
import { Html, Head, Main, NextScript } from 'next/document';
import { DataClientDocument } from '@data-client/ssr/nextjs';
export default class MyDocument extends DataClientDocument {
static async getInitialProps(ctx) {
const originalRenderPage = ctx.renderPage;
// Run the React rendering logic synchronously
ctx.renderPage = () =>
originalRenderPage({
// Useful for wrapping the whole react tree
enhanceApp: App => App,
// Useful for wrapping in a per-page basis
enhanceComponent: Component => Component,
});
// Run the parent `getInitialProps`, it now includes the custom `renderPage`
const initialProps = await super.getInitialProps(ctx);
return initialProps;
}
render() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
CSP Nonce
Reactive Data Client Document serializes the store state in a script tag. In case you have
Content Security Policy restrictions that require use of a nonce, you can override
DataClientDocument.getNonce.
Since there is no standard way of handling nonce in NextJS, this allows you to retrieve any nonce you created in the DocumentContext to use with Reactive Data Client.
import { DataClientDocument } from '@data-client/ssr/nextjs';
import type { DocumentContext } from 'next/document.js';
export default class MyDocument extends DataClientDocument {
static getNonce(ctx: DocumentContext & { res: { nonce?: string } }) {
// this assumes nonce has been added here - customize as you need
return ctx?.res?.nonce;
}
}
Express JS SSR
Generic @data-client/react/ssr (Express, Anansi) remains a one-shot document snapshot.
Incremental baseline-plus-delta is the Next.js App Router path in this release.
When implementing your own server using express.
Server side
import express from 'express';
import { renderToPipeableStream } from 'react-dom/server';
import {
createPersistedStore,
createServerDataComponent,
} from '@data-client/react/ssr';
const rootId = 'react-root';
const app = express();
app.get('/*', (req: any, res: any) => {
const [ServerDataProvider, useReadyCacheState, controller] =
createPersistedStore();
const ServerDataComponent =
createServerDataComponent(useReadyCacheState);
controller.fetch(NeededForPage, { id: 5 });
const { pipe, abort } = renderToPipeableStream(
<Document
assets={assets}
scripts={[<ServerDataComponent key="server-data" />]}
rootId={rootId}
>
<ServerDataProvider>{children}</ServerDataProvider>
</Document>,
{
onCompleteShell() {
// If something errored before we started streaming, we set the error code appropriately.
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-type', 'text/html');
pipe(res);
},
onError(x: any) {
didError = true;
console.error(x);
res.statusCode = 500;
pipe(res);
},
},
);
// Abandon and switch to client rendering if enough time passes.
// Try lowering this to see the client recover.
setTimeout(abort, 1000);
});
app.listen(3000, () => {
console.log(`Listening at ${PORT}...`);
});
Client
import { hydrateRoot } from 'react-dom';
import { awaitInitialData } from '@data-client/react/ssr';
const rootId = 'react-root';
awaitInitialData().then(initialState => {
hydrateRoot(
document.getElementById(rootId),
<DataProvider initialState={initialState}>{children}</DataProvider>,
);
});