Skip to content

React

React SSR (Server-Side Rendering) with Streaming Support

A lightweight, React SSR library with streaming capabilities, built for modern TypeScript applications. Designed as part of the taujs (τjs) ecosystem but fully standalone and framework-agnostic: bring any Node HTTP server (the server render paths need Node; the client surface is browser-only).

τjs @taujs/react

@taujs/react provides:

  • Streaming SSR with React 19 renderToPipeableStream
  • Static SSR with prerenderToNodeStream - complete HTML including React.lazy/use() content, bounded by a configurable render deadline
  • Automatic hydration with built-in data synchronisation
  • Type-safe data stores using React hooks
  • Robust error handling for production environments
  • Flexible logging for debugging and monitoring
  • Framework agnostic - works with Fastify, Express, native node:http or any Node HTTP server (both server render paths require Node capabilities)


Terminal window
npm install @taujs/react react react-dom

The SSRStore is a type-safe data container that synchronises data between server and client:

type SSRStore<T> = {
getSnapshot: () => T;
getServerSnapshot: () => T;
setData: (newData: T) => void;
subscribe: (callback: () => void) => () => void;
readonly status: "pending" | "success" | "error";
readonly lastError?: Error;
};

The createRenderer function creates a reusable renderer with your app component and configuration:

const { renderSSR, renderStream } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data, meta }) => `<title>${escapeHtml(data.title)}</title>`,
streamOptions: { shellTimeoutMs: 10000 },
});

Client-side hydration automatically:

  • Detects SSR data from window.__INITIAL_DATA__
  • Creates a synchronised store
  • Hydrates the React tree
  • Falls back to CSR if no SSR data exists

A streaming route may declare attr.deferred alongside attr.data - named, route-owned work whose value is allowed to arrive after rendering begins (see Route configuration). τjs starts each named loader once per request, outside the component tree, and @taujs/react projects the named promise onto React’s own use() + <Suspense> model. The component starts nothing and refetches nothing, and React’s own out-of-order streaming delivers the late HTML - τjs emits no patch protocol of its own.

import { Suspense } from "react";
import { createDeferredAccessor, useDeferredDataResult } from "@taujs/react";
// `export type ProductDeferredData = DeferredDataOf<typeof productRoute>` beside the route:
// a type-only import, so the client bundle pulls in no server code.
import type { ProductDeferredData } from "../server/routes/product";
// Typed from the route config alone - the payload shape is never re-declared here.
const useDeferred = createDeferredAccessor<ProductDeferredData>();
function Reviews() {
const reviews = useDeferred("reviews"); // suspends this boundary and nothing else
return <p>{reviews.count} reviews</p>;
}
export function ProductPage() {
return (
<main>
<ProductSummary />
<Suspense fallback={<p>Loading reviews…</p>}>
<Reviews />
</Suspense>
</main>
);
}

Two reads are available:

  • useDeferredData(key) (and the typed createDeferredAccessor façade above) returns the value. It suspends the nearest <Suspense> while the entry is pending and throws a DeferredDataError - carrying key and outcome - when the value never arrived. react-dom/server has no server-side error boundaries, so a thrown read errors the nearest boundary and React hands it to the client to render.
  • useDeferredDataResult(key) returns a DeferredResult<T> value: { status: 'complete', value }, { status: 'failed' } or { status: 'aborted' }. It still suspends natively while pending, then returns the outcome, so a handled fallback is rendered into the streamed document. Reach for this read for any entry that can fail.

failed and aborted carry no value, message, stack or cause. An unknown key is a deterministic developer error naming the declared set, never a suspended boundary and never undefined.

Boundary placement. React streams out of order, so a deferred boundary anywhere in the tree emits its fallback immediately and is patched in place once the value settles. Placement is a layout choice here, not a performance one. (This is not true of @taujs/vue, whose in-order stream makes placement load-bearing.)

Failure semantics. Every declared entry settles as exactly one of complete, failed or aborted, recorded once on the request episode. An unconsumed entry whose loader rejects after the response terminal reads as aborted, not failed - the response had already finished, so nothing was waiting on the value. complete means deliverable: a value that resolves but cannot cross the hydration boundary is classified failed uniformly across the server render, the episode and hydration.

Hydration. Outcomes for the boundaries a component actually read are seeded from a private internal carrier written beside window.__INITIAL_DATA__ and read synchronously in document order by the bootstrap. Every seeded entry is already settled, so use() returns or throws synchronously: no second render pass, no loader re-execution, no refetch. Under hydrate: false no carrier is emitted and the value still streams into the server-rendered HTML.


createRenderer<T, R = unknown, H = Record<string, unknown>>(options)

Section titled “createRenderer<T, R = unknown, H = Record<string, unknown>>(options)”

Creates a renderer instance with streaming and static SSR capabilities.

Type Signature:

function createRenderer<
T extends Record<string, unknown>,
R = unknown,
H extends Record<string, unknown> = Record<string, unknown>
>(options: {
appComponent: (props: { location: string; routeContext?: R }) => React.ReactElement;
headContent: (ctx: {
data: T;
headData?: H; // the route's attr.head payload, resolved by the host before the render
meta: Record<string, unknown>;
routeContext?: R;
}) => string;
...
}): {
// NB the RETURNED functions' host-facing parameters are deliberately BROAD (a renderer must
// stay assignable to @taujs/server's renderer-agnostic contracts regardless of your generics):
// T, R and H apply INSIDE your callbacks (appComponent, headContent), not at this boundary.
renderSSR: (
initialData: Record<string, unknown>,
location: string,
meta?: Record<string, unknown>,
signal?: AbortSignal,
opts?: { logger?: LoggerLike; routeContext?: unknown; headData?: Record<string, unknown> }
) => Promise<SSRResult>;
renderStream: (
writable: Writable,
callbacks: RenderCallbacks<T>,
initialData:
| Record<string, unknown>
| Promise<Record<string, unknown>>
| (() => Promise<Record<string, unknown>>),
location: string,
bootstrapModules?: string,
meta?: Record<string, unknown>,
cspNonce?: string,
signal?: AbortSignal,
opts?: StreamOptions & { logger?: LoggerLike; routeContext?: unknown; headData?: Record<string, unknown> }
) => { abort: () => void; done: Promise<void> };
}

Typing model: the generics narrow your OWN code - appComponent receives routeContext as R, headContent receives data: T and headData?: H. The returned render functions accept broad records at the host boundary, so standalone callers do not get compile-time narrowing on the values they pass in; the route config (with @taujs/server) or your own call-site discipline is the type authority for those.

bootstrapModules: URL to your client entry module. @taujs/react forwards it to React as a single-item bootstrapModules array.

done resolves on completion or benign cancellation and REJECTS on a fatal stream error. The rejection is pre-observed inside the renderer, so ignoring done can never crash your process with an unhandled rejection - but if you await it, handle the rejection.

routeContext is optional / advanced. See Advanced Patterns

Parameters:

  • appComponent: Function that returns your root React component, receives { location: string; routeContext?: R }
  • headContent: Function that generates HTML head content, receives { data: T, headData?: H, meta: Record<string, unknown>, routeContext?: R }. Note: In streaming mode, data may not be resolved when shell is ready - guard optional fields, rely on meta, or declare attr.head on the route so the host resolves DYNAMIC head data before the shell and passes it as headData (typed by createRenderer’s third generic H; undefined when the route declares no head or the loader degraded - handle it). Security: the return value is written into <head> verbatim as raw HTML - escape every value derived from data, headData or user input with escapeHtml (exported from @taujs/react; see the head management guide).
  • enableDebug: Enable detailed debug logging (default: false). warn and error always reach your logger (or the console) regardless - only debug-level logging is gated.
  • logger: Custom logger implementation
  • streamOptions: Streaming configuration
    • shellTimeoutMs: Timeout for the initial shell render (default: 10000)
    • dataTimeoutMs: Timeout for route data to settle after React has finished rendering (default: 30000). Bounds the stream so a never-settling loader cannot hold the response open indefinitely.
    • deferredTimeoutMs: The one response-level completion deadline for deferred route data. Default min(15000, dataTimeoutMs / 2), so 15000 with the stock dataTimeoutMs, and half of any smaller fatal backstop you configure. It is measured from renderStream entry - the same origin dataTimeoutMs is compared against - and armed at shell commit with whatever remains of that budget, because a deferred boundary can only be pending once the shell has committed (a budget already spent arms at 1ms rather than never). It is never reset per key and it latches: a boundary whose first read arrives after expiry is born aborted rather than starting a fresh unbounded wait. On expiry the still-pending boundaries a component actually read are abandoned through React’s own stream.abort, which emits React’s client-render instruction for each and finishes the document; the host then records those keys as aborted on the episode and in the hydration envelope. The value must be a positive finite number of milliseconds - there is no 0/Infinity disable sentinel, because this deadline is what keeps total response time bounded - and any other value throws a TypeError from createRenderer. It must also be strictly less than a finite dataTimeoutMs, or createRenderer throws: at or above the fatal route-data backstop, that backstop pre-empts graceful abandonment and the response fails instead of completing. Unlike the other stream timeouts it has no per-call form (see the per-call options below).
  • ssrOptions: Static SSR configuration
    • prerenderTimeoutMs: Deadline for the ssr strategy’s complete-HTML render (default: 10000; 0 or Infinity = wait forever; anything else must be a positive finite number or createRenderer throws). See renderSSR() for the expiry behaviour.
  • identifierPrefix: React’s useId prefix, passed to both rendering paths. Must match the value passed to hydrateApp for the same root. Set it when rendering more than one taujs root on a page so useId values are collision-free.

Returns:

Object with two rendering methods:

  • renderSSR: Static rendering (returns complete HTML)
  • renderStream: Streaming rendering (pipes to writable stream)

Per-call options (opts)

Both methods accept a final opts argument:

  • logger: Optional per-call logger (overrides the one passed to createRenderer)
  • routeContext: Per-request route context value forwarded into appComponent and headContent.
  • headData: The route’s resolved attr.head payload. With @taujs/server this is passed for you (only when the route declares attr.head and its loader resolved); standalone hosts may pass their own pre-resolved head data. Reaches headContent typed as H.
  • renderStream only: the per-call StreamOptions timeouts shellTimeoutMs and dataTimeoutMs may also be overridden here. deferredTimeoutMs is the one exception: it is factory-only and has no per-call form, because it is a boot-validated bound on the whole response rather than a per-request knob. A per-call dataTimeoutMs at or below the configured deferred deadline re-derives that deadline as dataTimeoutMs / 2 for that response, so the ordering against the fatal backstop holds without losing the graceful terminal; it can only ever move the graceful terminal earlier, never later.

Worked example: dynamic head data on a streamed route

entry-server.tsx
import { createRenderer, escapeHtml } from "@taujs/react";
import { App } from "./App";
type HeadData = { ogTitle?: string; ogDescription?: string };
export const { renderSSR, renderStream } = createRenderer<
Record<string, unknown>,
unknown,
HeadData
>({
appComponent: () => <App />,
// headData is undefined when the route declares no attr.head, and when the head loader
// degraded (deadline expiry, or an optional loader failure) - always handle it.
headContent: ({ headData, meta }) => `
<title>${escapeHtml(headData?.ogTitle ?? (meta.title as string | undefined) ?? "My App")}</title>
${
headData?.ogDescription
? `<meta name="description" content="${escapeHtml(headData.ogDescription)}">`
: ""
}
`,
});
// taujs.config.ts - the route side (with @taujs/server)
{
path: '/product/:id',
attr: {
render: 'streaming',
meta: { title: 'Products' }, // static fallback layer
data: serviceData('catalog', 'getProduct', mapper), // streams as usual
head: { data: serviceData('catalog', 'getProductHead', mapper) }, // resolved pre-shell
},
}

@taujs/react doesn’t know anything about your router, but it can carry whatever route information you have from the server into both:

  • your React tree (appComponent), and
  • your head generator (headContent).

This happens through a generic R type and a routeContext value:

  • On the server you pass routeContext into renderSSR / renderStream.
  • Inside the renderer, routeContext is forwarded to appComponent and headContent.

Example: route-aware renderer (standalone)

type RouteContext = {
name: "home" | "article";
path: string;
params: Record<string, string>;
};
type PageData = {
title: string;
description?: string;
};
const { renderSSR, renderStream } = createRenderer<PageData, RouteContext>({
appComponent: ({ location, routeContext }) => (
<App location={location} routeContext={routeContext} />
),
headContent: ({ data, meta, routeContext }) => {
const baseTitle =
data.title ?? (meta.title as string | undefined) ?? "My App";
const suffix =
routeContext?.name === "article"
? " · Article"
: routeContext?.name === "home"
? " · Home"
: "";
return `<title>${escapeHtml(baseTitle)}${suffix}</title>`;
},
});

On the server, you pass a RouteContext per request:

const routeContext: RouteContext = {
name: "article",
path: "/articles/hello-world",
params: { slug: "hello-world" },
};
const { headContent, appHtml } = await renderSSR(
data,
req.url!,
{ requestId }, // meta
abortSignal, // signal
{ routeContext } // opts
);

In streaming mode it’s the same:

renderStream(
res,
callbacks,
data,
req.url!,
"/assets/client.js",
{ requestId }, // meta
cspNonce, // cspNonce
abortSignal, // signal
{ routeContext } // opts
);

See Per-call options for opts.

Renders the complete application to a string (static SSR).

Uses React’s prerenderToNodeStream, so the result is COMPLETE HTML: React.lazy, use() and Suspense content is awaited and included (the legacy renderToString silently replaced any suspending subtree with its fallback). The render is bounded by ssrOptions.prerenderTimeoutMs:

  • render finishes in time: complete HTML, exactly as before for non-suspending trees (byte-identical output);
  • deadline expires with the shell complete: the page is served with its unfinished Suspense boundaries in their fallback state (the client completes them after hydration) and an advisory warning is logged;
  • deadline expires before the shell completes: renderSSR throws instead of returning a blank page - let it reach your error handling.

This path is Node-only (react-dom/static’s Node build).

Example:

const { renderSSR } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data }) => `<title>${escapeHtml(data.pageTitle)}</title>`,
});
const result = await renderSSR(
{ pageTitle: "Home", content: "Welcome!" },
"/home",
{ requestId: "123" }
);
// result: { headContent: string, appHtml: string, aborted: boolean }

For route-aware rendering, pass opts.routeContext and set the R generic on createRenderer


See Per-call options for opts.

Streams the application with React 19 streaming SSR.

Callbacks:

type RenderCallbacks<T> = {
onHead?: (head: string) => void; // required operationally: commits the head + connects the sink
onShellReady?: () => void; // advisory
onAllReady?: (data: T) => void; // advisory; fires once with the resolved route data
onFinish?: (data: T) => void; // deprecated alias of onAllReady
onError?: (err: unknown) => void; // FATAL channel only
onRenderError?: (info: {
error: unknown;
phase: "pre-shell" | "post-shell";
recoverable: boolean | "unknown";
}) => void; // advisory, NON-fatal render errors
};

Error channel semantics: onError fires only for fatal stream failures (shell error, timeouts, writable guards). React render errors that do NOT fail the response - notably post-shell Suspense boundary errors, which React recovers client-side - arrive on onRenderError instead, with the observed phase and a recoverable flag that is true only where React’s semantics guarantee it (post-shell). A throwing onHead is fatal (the response cannot proceed without its head).

Output ownership & backpressure

@taujs/react does not assemble a full HTML document and does not manage socket-level backpressure for your response. In streaming mode it:

  • calls onHead(head) when the shell is ready, and
  • pipes the React HTML stream to the writable you provide.

If you are writing an HTML template prefix/suffix (common for Express/Fastify), handle backpressure on the final destination stream (res / reply.raw) and delay piping until the prefix write is accepted or the destination drains.

Example:

const { renderStream } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data }) => `<title>${escapeHtml(data.pageTitle)}</title>`,
});
const { done, abort } = renderStream(
res, // Node.js Writable stream (e.g., http.ServerResponse)
{
onHead: (head) => {
console.log("Shell ready, head HTML:", head);
},
onShellReady: () => {
console.log("Initial HTML ready");
},
onAllReady: (data) => {
console.log("All content ready:", data);
},
onError: (err) => {
console.error("Stream error:", err);
},
},
{ pageTitle: "Home", content: "Welcome!" },
"/home",
"/assets/client.js", // bootstrap module
{ requestId: "123" }, // meta
"nonce-abc123", // CSP nonce
abortSignal // AbortSignal for cancellation
);
// Wait for completion
await done;

For route-aware rendering, pass opts.routeContext and set the R generic on createRenderer


Hydrates a server-rendered React application on the client.

Type Signature:

function hydrateApp<T>(options: {
appComponent: React.ReactElement;
rootElementId?: string;
enableDebug?: boolean;
logger?: LoggerLike;
dataKey?: string;
identifierPrefix?: string;
onHydrationError?: (err: unknown) => void;
onStart?: () => void;
onSuccess?: () => void;
}): void;

Parameters:

  • appComponent: Your root React component (already instantiated)
  • rootElementId: DOM element ID to hydrate into (default: 'root')
  • enableDebug: Enable debug logging (default: false). Debug logging is lifecycle messages only - the route-data payload and the store object are never logged, so a logger that forwards to a server sink cannot disclose request data.
  • logger: Custom logger implementation
  • dataKey: Key for initial data on window object (default: '__INITIAL_DATA__')
  • identifierPrefix: React’s useId prefix, passed to both hydrateRoot and the CSR-fallback createRoot. Must be identical to the value the server rendered with (createRenderer’s identifierPrefix), or hydration mismatches.
  • onHydrationError: Callback for client render failures on EITHER path (hydration or CSR fallback). Fires at most once per hydrateApp call - settlement is single-fire.
  • onStart: Callback when hydration begins (hydrate path only - it does not fire for the CSR fallback)
  • onSuccess: Callback on the FIRST ROOT COMMIT (also on the CSR path). This signals the root committed - it does not claim every Suspense boundary has hydrated.

Example:

// entry-client.tsx (with @taujs/server)
import { hydrateApp } from "@taujs/react";
import { App } from "./App";
hydrateApp({
appComponent: <App />,
rootElementId: "root",
enableDebug: import.meta.env.DEV,
onHydrationError: (err) => {
console.error("Hydration failed:", err);
},
onSuccess: () => {
console.log("App root committed");
},
});

Complete Client Setup (Standalone):

entry-client.tsx
import React from "react";
import { hydrateApp, useSSRStore } from "@taujs/react";
// Your app component that consumes SSR data
function App() {
const data = useSSRStore<{ pageTitle: string; content: string }>();
return (
<div>
<h1>{data.pageTitle}</h1>
<p>{data.content}</p>
</div>
);
}
// Hydrate the app
hydrateApp({
appComponent: <App />,
rootElementId: "root",
dataKey: "__INITIAL_DATA__",
enableDebug: process.env.NODE_ENV === "development",
onHydrationError: (err) => {
console.error("Client render failed:", err);
// Optionally report to error tracking service
},
onStart: () => {
console.log("Starting hydration...");
},
onSuccess: () => {
// First root commit - fires on the hydrate AND CSR-fallback paths
console.log("App root committed");
// Initialise client-side only features
initialiseAnalytics();
},
});

Behaviour:

  • If window[dataKey] exists: Performs SSR hydration with the provided data
  • If window[dataKey] is missing: Falls back to client-side rendering (CSR) from empty data
  • If the root element is missing: reports through onHydrationError (nothing is mounted)
  • Callbacks are isolated: a throwing onStart/onSuccess/onHydrationError is logged and can neither break hydration nor tear down the committed tree
  • Recoverable hydration mismatches (React repairs the DOM and continues) and errors caught by your own error boundaries are logged only - caught errors at error level, recoverable mismatches at warning level - and settle NEITHER callback. Settlement is reserved for the first root commit (onSuccess) vs the first uncaught client render error (onHydrationError), exactly one of which fires per hydrateApp call
  • Uncaught render errors still surface to global monitoring (window error / reportError) in addition to onHydrationError
  • Automatically handles DOMContentLoaded timing
  • Wraps your app in React.StrictMode and SSRStoreProvider

HTML Template Requirements:

Your server-rendered HTML must include:

  1. The root element:
<div id="root"><!-- SSR content here --></div>
  1. Initial data script (injected by server):
<script>
window.__INITIAL_DATA__ = { pageTitle: "Home", content: "Welcome" };
</script>
  1. Client bundle:
<script type="module" src="/assets/entry-client.js"></script>

Creates a store for managing SSR data synchronisation.

Type Signature:

function createSSRStore<T>(
initialDataOrPromise: T | Promise<T> | (() => Promise<T>)
): SSRStore<T>;

Parameters:

  • initialDataOrPromise: Can be:
    • Synchronous data: T
    • Promise: Promise<T>
    • Lazy promise factory: () => Promise<T>

Returns: SSRStore<T> with methods:

  • getSnapshot() / getServerSnapshot(): Read the current data. Both throw the underlying promise while status is ‘pending’, so they integrate with React Suspense; both throw an Error if status is ‘error’.
  • setData(newData): Replace the stored value and notify subscribers. Transitions status to ‘success’. An explicit setData supersedes the store’s in-flight initial promise: a later settlement of that promise (success or failure) is ignored - it can neither overwrite the explicit value nor flip the store into an error state.
  • subscribe(callback): Register a change listener; returns the unsubscribe function.
  • status (readonly): Live lifecycle of the store - ‘pending’ until the initial promise settles, then ‘success’ or ‘error’. Always reflects the current state, including transitions after the store was created.
  • lastError (readonly): undefined unless status is ‘error’, in which case it holds the Error that caused the failure.

Example:

import { createSSRStore } from "@taujs/react";
// Synchronous data
const store1 = createSSRStore({ user: "Alice", posts: [] });
// Promise
const store2 = createSSRStore(fetch("/api/data").then((r) => r.json()));
// Lazy promise
const store3 = createSSRStore(async () => {
const data = await fetch("/api/data");
return data.json();
});

Notes:

  • Inside components, prefer useSSRStore - it consumes the store via React Suspense, so getSnapshot() throws the pending promise and the component suspends until data is ready.

  • Outside of rendering - for logging, conditional setup, or non-Suspense code paths - read status and lastError directly. They are live, so they update as the store transitions:

    const store = createSSRStore(loadData);
    store.subscribe(() => {
    if (store.status === "error") {
    reportFailure(store.lastError);
    }
    });

    see useSSRStore


React hook to consume data from an SSR store.

Type Signature:

function useSSRStore<T>(): T;

Usage:

import { useSSRStore, SSRStoreProvider } from "@taujs/react";
function MyComponent() {
const data = useSSRStore<{ user: string; posts: string[] }>();
return (
<div>
<h1>Welcome, {data.user}</h1>
<ul>
{data.posts.map((post) => (
<li key={post}>{post}</li>
))}
</ul>
</div>
);
}
// In your app component
function App() {
return (
<SSRStoreProvider store={store}>
<MyComponent />
</SSRStoreProvider>
);
}

Notes:

  • Must be used within SSRStoreProvider - throws otherwise.
  • Subscribes via useSyncExternalStore, so the component re-renders on setData or once the initial promise resolves - the hook always returns the store’s current value.
  • Suspension caveat (official React guidance): a render triggered by a store MUTATION cannot be a non-blocking Transition. If the render caused by a setData suspends through constructs derived from the store value (a React.lazy component selected by it, or a use() promise built from it), React replaces already-revealed content with the nearest Suspense fallback. After hydration, drive suspension-prone UI from component state inside startTransition rather than directly from store values - see the useSyncExternalStore caveats.
  • Suspends automatically while data is loading. The store’s getSnapshot throws the underlying promise when status is ‘pending’, which the nearest <Suspense fallback={...}> ancestor catches until the promise settles. If the promise rejects, getSnapshot throws an Error instead - surface it with an error boundary.
  • For code that runs outside React (logging, conditional bootstrap, side-effect setup), read store.status and store.lastError directly - see Data Store.

Context provider for SSR store access.

Type Signature:

function SSRStoreProvider<T>(props: {
store: SSRStore<T>;
children: React.ReactNode;
}): JSX.Element;

Escape a value for safe interpolation into HTML text or quoted attributes (& < > " '; non-strings are coerced with String()).

import { escapeHtml } from "@taujs/react";
headContent: ({ data }) => `<title>${escapeHtml(data.pageTitle)}</title>`;

Use it for every headContent value derived from data or user input. It is for HTML text and quoted attributes ONLY - not for JavaScript, <script>/JSON-LD bodies, CSS, or URL scheme validation (for inline JSON use JSON.stringify(value).replace(/</g, "\u003c"); see the head management guide).


Wrapper around @vitejs/plugin-react for use in taujs ecosystem.

Type Signature:

function pluginReact(opts?: Parameters<typeof react>[0]): PluginOption;

Usage:

vite.config.ts
import { defineConfig } from "vite";
import { pluginReact } from "@taujs/react/plugin";
export default defineConfig({
plugins: [pluginReact()],
});

τjs governs the response for a URL. What a subsequent navigation does is renderer-independent: a navigation between τjs apps is a full document load and a new governed response, while within one application the app’s own client router (React Router, TanStack Router or a custom router) owns the transition and any data it needs. See Navigation boundaries for the model, and App Shell Architecture when one shell composes several sections client-side.

For data that changes after the first render, fetch it from your own /api/* endpoints with a client library such as TanStack Query or SWR. τjs keeps SSR request-first and adds no generic client data channel; the initial snapshot stays the authority for the response it was built for.


The @taujs/react package is designed to integrate seamlessly with @taujs/server, which handles all the SSR rendering, routing, and data fetching automatically. When used together, you don’t need to call the rendering functions directly - @taujs/server handles everything through declarative configuration.

Declarative Configuration (taujs.config.ts)

Section titled “Declarative Configuration (taujs.config.ts)”
taujs.config.ts
import { pluginReact } from "@taujs/react/plugin";
import { defineConfig } from "@taujs/server/config";
export default defineConfig({
apps: [
{
appId: "root",
entryPoint: "", // Root app
plugins: [pluginReact()],
routes: [
{
path: "/:id",
attr: {
// Data fetching for this route
data: async (params, ctx) => {
const res = await fetch(
`http://localhost:5173/api/initial/${params.id}`,
{
headers: ctx.headers,
}
);
return await res.json();
},
// Per-route CSP configuration
middleware: {
csp: {
directives: ({ params }) => {
const userId = params.id as string;
return {
"script-src": [
"'self'",
`https://user-${userId}.example.com`,
],
};
},
},
},
render: "ssr", // Static SSR
},
},
{
path: "/:id/:another",
attr: {
// Service registry pattern (alternative to HTTP fetch)
data: async (params) => ({
serviceName: "ServiceExample",
serviceMethod: "exampleMethod",
args: params,
}),
meta: {
title: "τjs [taujs] - streaming",
description: "Streaming page description from route meta",
},
render: "streaming", // Streaming SSR
},
},
],
},
{
appId: "mfe",
entryPoint: "@admin", // Microfrontend
plugins: [pluginReact()],
routes: [
{
path: "/mfe/:id",
attr: {
data: async (params, ctx) => {
const res = await fetch(
`http://localhost:5173/api/initial/${params.id}`,
{
headers: ctx.headers,
}
);
return await res.json();
},
render: "ssr",
},
},
],
},
],
});

Pattern 1: Full Delegation (Recommended)

server.ts
import path from "node:path";
import Fastify from "fastify";
import { createServer } from "@taujs/server";
import { serviceRegistry } from "./services";
import config from "./taujs.config";
const clientRoot = path.resolve(__dirname, "../client");
const fastify = Fastify({ logger: false });
// taujs/server handles all SSR, routing, and rendering
await createServer({
fastify,
config,
serviceRegistry,
clientRoot,
alias: { "@client": clientRoot },
});
// Add your API routes
fastify.get("/api/initial/:id?", (request, reply) => {
const { id } = request.params;
if (id) {
setTimeout(() => {
reply.send({
title: `τjs [taujs] - ${id}`,
description: `HTTP API call response with - ${id}`,
});
}, 1000);
} else {
reply.send({ data: "HTTP API call response" });
}
});
await fastify.listen({ port: 5173, host: "0.0.0.0" });

Pattern 2: Explicit Registration

server.ts
import path from "node:path";
import Fastify from "fastify";
import fastifyStatic from "@fastify/static";
import { createServer } from "@taujs/server";
import { serviceRegistry } from "./services";
import config from "./taujs.config";
const clientRoot = path.resolve(__dirname, "../client");
const startServer = async () => {
try {
const fastify = Fastify({ logger: false });
// Optional: Add compression
await fastify.register(import("@fastify/compress"), { global: true });
// Register taujs/server with explicit static assets (see Static assets guide)
const { net } = await createServer({
fastify,
clientRoot,
config,
serviceRegistry,
staticAssets: {
plugin: fastifyStatic,
},
debug: { all: false, ssr: true },
});
// Add API routes
fastify.get("/api/initial/:id?", (request, reply) => {
const { id } = request.params;
if (id) {
setTimeout(() => {
reply.send({
title: `τjs [taujs] - ${id}`,
description: `HTTP API call response with - ${id}`,
});
}, 1000);
} else {
reply.send({ data: "HTTP API call response with route meta" });
}
});
await fastify.listen(
{
port: net.port,
host: net.host,
},
(err, address) => {
if (err) {
fastify.log.error(err);
process.exit(1);
}
}
);
} catch (error) {
console.error("Error starting server:", error);
process.exit(1);
}
};
startServer();

Instead of making HTTP calls in your data loaders, use the service registry pattern:

services/index.ts
import { defineServiceRegistry, defineService } from "@taujs/server";
const ServiceExample = defineService({
exampleMethod: async (params: { id: string; another: string }, ctx) => {
// Business logic here
return {
title: `Service Response`,
data: params,
userId: ctx.user?.id,
};
},
});
export const serviceRegistry = defineServiceRegistry({
ServiceExample,
});
// taujs.config.ts - Use service instead of HTTP
{
path: '/:id/:another',
attr: {
data: async (params) => ({
serviceName: 'ServiceExample',
serviceMethod: 'exampleMethod',
args: params,
}),
render: 'streaming',
},
}

When you use @taujs/server, the host builds the route context for you - { appId, path, attr, params } from the matched route. Type the generic with the exported RouteContext: the bare form is the broad runtime shape, and RouteContext<typeof config> narrows appId, path and attr to the routes your config declares:

import { createRenderer, escapeHtml } from "@taujs/react";
import config from "../taujs.config";
import { AppBootstrap } from "./AppBootstrap";
import type { RouteContext } from "@taujs/server/config";
export const { renderSSR, renderStream } = createRenderer<
Record<string, unknown>,
RouteContext<typeof config>
>({
appComponent: ({ location, routeContext }) => (
<AppBootstrap location={location} routeContext={routeContext} />
),
headContent: ({ data, meta, routeContext }) => {
const baseTitle =
(data as Record<string, unknown>)?.title ??
(meta.title as string | undefined) ??
"My App";
return `<title>${escapeHtml(baseTitle)}</title>`;
},
});

@taujs/server builds a matching routeContext object from the matched route (appId, path, attr, params) and passes it into renderSSR / renderStream for you. Route data is not on it - that arrives through the SSR store.

  1. Automatic Rendering: @taujs/server uses @taujs/react internally - you don’t call createRenderer directly
  2. Declarative Routes: Define routes with data loading and rendering strategy in config
  3. CSP Integration: Per-route CSP policies with dynamic directives
  4. Service Registry: Type-safe service calls instead of HTTP
  5. Microfrontend Support: Multiple apps with separate entry points
  6. Development Mode: Automatic HMR and Vite integration
  7. Static Assets: Automatic handling of static files and manifests

server.tsx
import express from "express";
import { createRenderer, escapeHtml } from "@taujs/react";
import { App } from "./App";
const app = express();
const { renderStream } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data }) => `<title>${escapeHtml(data.pageTitle)}</title>`,
});
app.get("*", async (req, res) => {
const data = await fetchPageData(req.path);
res.setHeader("Content-Type", "text/html; charset=utf-8");
let piped = false;
const startPipe = () => {
if (piped) return;
piped = true;
// renderer will pipe into `res` once shell is ready (or immediately after onHead),
// depending on your implementation
};
const { done } = renderStream(
res,
{
onHead: (head) => {
const prefix = `<!doctype html>
<html>
<head>
<meta charset="UTF-8">
${head}
<script>window.__INITIAL_DATA__=${JSON.stringify(data).replace(
/</g,
"\\u003c"
)};</script>
</head>
<body><div id="root">`;
const ok = res.write(prefix);
if (!ok) res.once("drain", startPipe);
else startPipe();
},
onAllReady: () => {
res.write(
`</div><script type="module" src="/assets/client.js"></script></body></html>`
);
res.end();
},
onError: (err) => {
console.error("Stream error:", err);
if (!res.writableEnded) res.destroy();
},
},
data,
req.path,
"/assets/client.js"
);
await done.catch(() => {});
});
app.listen(3000, () => {
console.log("Server listening on port 3000");
});
server.tsx
import http from "node:http";
import { createRenderer, escapeHtml } from "@taujs/react";
import { App } from "./App";
const { renderStream } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data }) => `<title>${escapeHtml(data.pageTitle)}</title>`,
});
const server = http.createServer(async (req, res) => {
const data = { pageTitle: "Home", content: "Hello World" };
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<script>window.__INITIAL_DATA__=${JSON.stringify(data).replace(/</g, "\\u003c")};</script>
</head>
<body><div id="root">
`);
const { done } = renderStream(
res,
{
onAllReady: () => {
res.write(`</div></body></html>`);
res.end();
},
onError: (err) => {
console.error(err);
res.destroy();
},
},
data,
req.url || "/",
"/assets/client.js"
);
await done.catch(() => {}); // Error already handled in callback
});
server.listen(3000);
server.tsx
import { Hono } from "hono";
import { createRenderer, escapeHtml } from "@taujs/react";
import { streamSSR } from "hono/streaming";
import { App } from "./App";
const app = new Hono();
const { renderStream } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data }) => `<title>${escapeHtml(data.pageTitle)}</title>`,
});
app.get("*", async (c) => {
const data = { pageTitle: "Home", content: "Hello from Hono" };
return streamSSR(c, async (stream) => {
await stream.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<script>window.__INITIAL_DATA__=${JSON.stringify(data).replace(/</g, "\\u003c")};</script>
</head>
<body><div id="root">
`);
const { done } = renderStream(
stream,
{
onAllReady: async () => {
await stream.write(`</div></body></html>`);
},
onError: (err) => {
console.error(err);
},
},
data,
c.req.path,
"/assets/client.js"
);
await done;
});
});
export default app;

For simpler cases where you want complete HTML in a single response. NB renderSSR uses react-dom/static’s Node build (complete HTML including Suspense content, bounded by ssrOptions.prerenderTimeoutMs) - it requires a Node runtime:

server.tsx
import { createRenderer, escapeHtml } from "@taujs/react";
import { App } from "./App";
const { renderSSR } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data }) => `<title>${escapeHtml(data.pageTitle)}</title>`,
});
import http from "node:http";
const server = http.createServer(async (req, res) => {
const data = { pageTitle: "Home", content: "Hello World" };
const { headContent, appHtml } = await renderSSR(data, req.url || "/");
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
${headContent}
<script>window.__INITIAL_DATA__=${JSON.stringify(data).replace(/</g, "\\u003c")};</script>
</head>
<body>
<div id="root">${appHtml}</div>
<script type="module" src="/assets/client.js"></script>
</body>
</html>
`;
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(html);
});
server.listen(3000);

Full Project Structure (with @taujs/serve)

Section titled “Full Project Structure (with @taujs/serve)”
my-app/
├── client/
│ ├── entry-client.tsx # Client hydration entry
│ ├── entry-server.tsx # Server render entry
│ ├── index.html # HTML template
│ ├── App.tsx # Root component
│ └── components/
│ └── HomePage.tsx
├── server/
│ ├── index.ts # Server bootstrap
│ ├── services/
│ │ └── index.ts # Service registry
│ └── utils/
│ └── index.ts
├── taujs.config.ts # taujs configuration
├── tsconfig.json
├── package.json
└── vite.config.ts

client/entry-client.tsx:

import { hydrateApp } from "@taujs/react";
import { App } from "./App";
hydrateApp({
appComponent: <App />,
enableDebug: import.meta.env.DEV,
});

client/entry-server.tsx:

import { createRenderer, escapeHtml } from "@taujs/react";
import { App } from "./App";
export const { renderSSR, renderStream } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data, meta }) => `
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(data?.title ?? meta.title ?? "My App")}</title>
${
data?.description
? `<meta name="description" content="${escapeHtml(data.description)}">`
: ""
}
`,
enableDebug: process.env.NODE_ENV === "development",
});

client/index.html:

<!DOCTYPE html>
<html lang="en">
<head>
<!--ssr-head-->
<script type="module" src="/@vite/client"></script>
</head>
<body>
<div id="root"><!--ssr-html--></div>
<script type="module" src="/entry-client.tsx"></script>
</body>
</html>

client/App.tsx:

import { useSSRStore } from "@taujs/react";
import { HomePage } from "./components/HomePage";
export function App({ location }: { location?: string }) {
const data = useSSRStore<{
title: string;
description: string;
userId?: string;
}>();
return (
<div>
<h1>{data.title}</h1>
<p>{data.description}</p>
{data.userId && <HomePage userId={data.userId} />}
</div>
);
}

server/index.ts:

import path from "node:path";
import Fastify from "fastify";
import { createServer } from "@taujs/server";
import { serviceRegistry } from "./services";
import config from "../taujs.config";
const clientRoot = path.resolve(__dirname, "../client");
const fastify = Fastify({ logger: false });
await createServer({
fastify,
config,
serviceRegistry,
clientRoot,
debug: { ssr: true },
});
// API routes
fastify.get("/api/users/:id", async (request, reply) => {
const user = await db.users.findById(request.params.id);
return { user };
});
await fastify.listen({ port: 5173, host: "0.0.0.0" });

server/services/index.ts:

import { defineServiceRegistry, defineService } from "@taujs/server";
const UserService = defineService({
getUser: async (params: { id: string }, ctx) => {
const user = await db.users.findById(params.id);
return {
user,
title: `User: ${user.name}`,
description: user.bio,
};
},
});
export const serviceRegistry = defineServiceRegistry({
UserService,
});

taujs.config.ts:

import { pluginReact } from "@taujs/react/plugin";
import { defineConfig } from "@taujs/server/config";
export default defineConfig({
apps: [
{
appId: "root",
entryPoint: "",
plugins: [pluginReact()],
routes: [
{
path: "/",
attr: {
data: async () => ({
title: "Home",
description: "Welcome to my app",
}),
render: "ssr",
},
},
{
path: "/users/:id",
attr: {
data: async (params) => ({
serviceName: "UserService",
serviceMethod: "getUser",
args: params,
}),
render: "streaming",
},
},
],
},
],
});

package.json:

{
"name": "my-taujs-app",
"type": "module",
"scripts": {
"dev": "tsx server/index.ts",
"build": "taujs build",
"start": "NODE_ENV=production tsx server/index.ts"
},
"dependencies": {
"@taujs/react": "^0.2.1",
"@taujs/server": "latest",
"fastify": "^5.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^24.0.7",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.6.0",
"tsx": "^4.0.0",
"typescript": "^5.5.4",
"vite": "^7.1.9"
}
}

Standalone Project Structure (No @taujs/server)

Section titled “Standalone Project Structure (No @taujs/server)”
my-app/
├── src/
│ ├── client/
│ │ ├── index.tsx # Client entry
│ │ └── App.tsx
│ ├── server/
│ │ ├── index.ts # Express/Node server
│ │ └── render.tsx # SSR renderer
│ └── shared/
│ └── types.ts
├── public/
├── dist/ # Build output
├── tsconfig.json
├── package.json
└── vite.config.ts

src/client/index.tsx:

import { hydrateApp } from "@taujs/react";
import { App } from "./App";
hydrateApp({
appComponent: <App />,
});

src/server/render.tsx:

import { createRenderer, escapeHtml } from "@taujs/react";
import { App } from "../client/App";
export const { renderSSR, renderStream } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data }) => `
<meta charset="UTF-8">
<title>${escapeHtml(data.pageTitle)}</title>
`,
});

src/server/index.ts:

import express from "express";
import { renderStream } from "./render";
const app = express();
app.use(express.static("dist/client"));
app.get("*", async (req, res) => {
const data = { pageTitle: "Home", content: "Hello" };
res.setHeader("Content-Type", "text/html");
res.write(`
<!DOCTYPE html>
<html>
<head>
<script>window.__INITIAL_DATA__=${JSON.stringify(data).replace(/</g, "\\u003c")};</script>
</head>
<body><div id="root">
`);
const { done } = renderStream(
res,
{
onAllReady: () => {
res.write(`</div>
<script type="module" src="/assets/index.js"></script>
</body></html>`);
res.end();
},
},
data,
req.url
);
await done;
});
app.listen(3000);

vite.config.ts:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: {
outDir: "dist/client",
manifest: true,
rollupOptions: {
input: {
main: "src/client/index.tsx",
},
},
},
});

When you use @taujs/server, it handles all the rendering automatically. Here’s what happens behind the scenes:

  1. Client requests GET /users/123

  2. @taujs/server matches route from your config:

    {
    path: '/users/:id',
    attr: {
    data: async (params) => ({ ... }),
    render: 'streaming',
    },
    }
  3. Data loading - Calls your data function with route params:

    const initialData = await attr.data({ id: "123" }, context);
  4. @taujs/react rendering - Server internally uses:

    // For render: 'ssr'
    const { headContent, appHtml } = await renderSSR(initialData, location);
    // For render: 'streaming'
    renderStream(res, callbacks, initialData, location, ...);
  5. HTML assembly - Combines:

    • Your HTML template from index.html
    • Generated head content
    • SSR-rendered app HTML
    • Initial data injection: window.__INITIAL_DATA__
    • Client bootstrap script
  6. Response - Sends complete HTML to browser

  7. Client hydration - Your entry-client.tsx runs:

    hydrateApp({ appComponent: <App /> });

entry-server.tsx (per app):

// This is what `@taujs/server` calls internally
export const { renderSSR, renderStream } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data, meta }) =>
`<title>${escapeHtml(data?.title ?? meta.title ?? "App")}</title>`,
});

entry-client.tsx (per app):

// This runs in the browser after SSR
hydrateApp({
appComponent: <App />,
});
  • Declarative testable configuration
  • Route matching and parameter extraction
  • Data loading (HTTP or service registry)
  • Calling your renderer with the right data
  • HTML template processing
  • Initial data injection
  • Static asset management
  • Development mode with HMR
  • Production manifests and preloading
  • CSP headers and nonce generation
  • Error handling and fallbacks
  • Standardised logging / telemetry

With @taujs/server, you:

  • Define rendering logic once in entry-server.tsx
  • Define hydration once in entry-client.tsx
  • Configure routes and data in taujs.config.ts

Remember to:

  • Never call renderSSR or renderStream directly
  • Never manually inject __INITIAL_DATA__
  • Never manually manage HTML templates

The server orchestrates everything based on your declarative configuration.


const { renderStream } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data }) => `<title>${escapeHtml(data.pageTitle)}</title>`,
});
app.get("/products/:id", async (req, res) => {
res.setHeader("Content-Type", "text/html");
// Lazy data loading - promise created only when needed
const dataLoader = async () => {
const product = await db.products.findById(req.params.id);
const reviews = await db.reviews.findByProduct(req.params.id);
return { product, reviews, pageTitle: product.name };
};
res.write("<!DOCTYPE html><html><head>");
const { done } = renderStream(
res,
{
onHead: (head) => {
// Shell ready but data may still be loading
res.write(head);
res.write('</head><body><div id="root">');
},
onAllReady: (data) => {
// All Suspense boundaries resolved
res.write(`</div>
<script>window.__INITIAL_DATA__=${JSON.stringify(data).replace(/</g, "\\u003c")};</script>
<script type="module" src="/assets/client.js"></script>
</body></html>`);
res.end();
},
onError: (err) => {
console.error(err);
res.end("</body></html>");
},
},
dataLoader, // Pass the function, not the result
req.url,
"/assets/client.js"
);
await done;
});
import winston from "winston";
import { createRenderer, escapeHtml } from "@taujs/react";
const logger = winston.createLogger({
level: "info",
format: winston.format.json(),
transports: [new winston.transports.Console()],
});
const customLogger = {
info: (message: string, meta?: unknown) => {
logger.info(message, meta);
},
warn: (message: string, meta?: unknown) => {
logger.warn(message, meta);
},
error: (message: string, meta?: unknown) => {
logger.error(message, meta);
},
debug: (category: string, message: string, meta?: unknown) => {
logger.debug(`[${category}] ${message}`, meta);
},
child: (ctx: Record<string, unknown>) => {
return logger.child(ctx);
},
isDebugEnabled: (category: string) => {
return logger.level === "debug";
},
};
const { renderStream } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data }) => `<title>${escapeHtml(data.pageTitle)}</title>`,
logger: customLogger,
enableDebug: true,
});
app.get("*", async (req, res) => {
const controller = new AbortController();
// Cancel render if client disconnects
res.on("close", () => {
controller.abort();
});
// Timeout after 30 seconds
const timeout = setTimeout(() => {
controller.abort();
}, 30000);
const { done } = renderStream(
res,
{
onAllReady: () => {
clearTimeout(timeout);
res.end();
},
onError: (err) => {
clearTimeout(timeout);
console.error(err);
},
},
data,
req.url,
"/assets/client.js",
{},
undefined,
controller.signal // Pass AbortSignal
);
try {
await done;
} catch (err) {
// Handle cancellation
if (controller.signal.aborted) {
console.log("Render cancelled");
}
}
});
import crypto from "node:crypto";
app.get("*", (req, res) => {
const nonce = crypto.randomBytes(16).toString("base64");
res.setHeader(
"Content-Security-Policy",
`script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none';`
);
const { done } = renderStream(
res,
callbacks,
data,
req.url,
"/assets/client.js",
{},
nonce // CSP nonce applied to React's renderToPipeableStream
);
return done;
});

Important: The nonce is automatically applied to:

  • React’s internal renderToPipeableStream (via the nonce option)
  • All inline scripts you write in onHead, onAllReady, etc. must also include the nonce:
    {
    onAllReady: (data) => {
    res.write(`<script nonce="${nonce}">
    window.__INITIAL_DATA__ = ${JSON.stringify(data).replace(/</g, "\\u003c")};
    </script>`);
    },
    }
App.tsx
import { Component, ReactNode } from "react";
class ErrorBoundary extends Component<
{ children: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, info: any) {
console.error("React Error Boundary caught:", error, info);
}
render() {
if (this.state.hasError) {
return <div>Something went wrong. Please refresh.</div>;
}
return this.props.children;
}
}
export function App() {
return (
<ErrorBoundary>
<YourAppContent />
</ErrorBoundary>
);
}
import { matchPath } from "path-to-regexp";
const routes = [
{
path: "/",
loader: async () => ({ pageTitle: "Home", posts: await db.posts.recent() }),
},
{
path: "/users/:id",
loader: async (params: any) => ({
pageTitle: "User Profile",
user: await db.users.findById(params.id),
}),
},
{
path: "/products/:id",
loader: async (params: any) => ({
pageTitle: "Product",
product: await db.products.findById(params.id),
}),
},
];
app.get("*", async (req, res) => {
let data = { pageTitle: "404 Not Found" };
for (const route of routes) {
const match = matchPath(route.path, req.path);
if (match) {
data = await route.loader(match.params);
break;
}
}
const { done } = renderStream(res, callbacks, data, req.url);
await done;
});

The package is written in TypeScript with full type definitions included.

interface PageData {
pageTitle: string;
user: {
id: string;
name: string;
email: string;
};
posts: Array<{
id: string;
title: string;
content: string;
}>;
}
const { renderSSR, renderStream } = createRenderer<PageData>({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data, meta }) => {
// data is typed as PageData
return `<title>${escapeHtml(data.pageTitle)}</title>`;
},
});
// In your component
function MyComponent() {
const data = useSSRStore<PageData>();
// data is fully typed
return <h1>{data.user.name}</h1>;
}
import type { ServerLogs } from "@taujs/react";
const myLogger: ServerLogs = {
info: (message, meta) => {
console.log(message, meta);
},
warn: (message, meta) => {
console.warn(message, meta);
},
error: (message, meta) => {
console.error(message, meta);
},
debug: (category, message, meta) => {
console.debug(`[${category}]`, message, meta);
},
};

When using renderStream in production, ensure you have the following in place:

  • Set shell timeout: Configure shellTimeoutMs appropriately (default: 10s)

    createRenderer({
    streamOptions: { shellTimeoutMs: 5000 },
    });

    Note: The host may optionally use cork/uncork on the response stream to coalesce writes.

  • Handle AbortSignal: Pass request cancellation signals

    const controller = new AbortController();
    req.on("close", () => controller.abort());
    renderStream(
    res,
    callbacks,
    data,
    location,
    undefined,
    {},
    undefined,
    controller.signal
    );
  • Set request timeouts: Prevent indefinite hangs

    const timeout = setTimeout(() => controller.abort(), 30000);
    await done.finally(() => clearTimeout(timeout));
  • Client disconnects are benign: The renderer automatically treats these as non-fatal:

  • ECONNRESET, EPIPE - network errors

  • socket hang up - client closed connection

  • aborted - request cancelled

  • premature close - stream ended early

    Classification is origin-aware: only genuine socket/writable errors are treated as benign - a RENDER error whose message merely looks like a disconnect is never reclassified.

  • Implement onError callback: Always handle errors gracefully

{
onError: (err) => {
logger.error('Stream error', err);
if (!res.writableEnded) {
res.end('<html><body>Error loading page</body></html>');
}
},
}
  • Respect backpressure: Return false from onHead to wait for drain
{
onHead: (head) => {
const writeOk = res.write(head);
return writeOk; // false means wait for drain
},
}

Streaming SSR provides better Time To First Byte (TTFB) by sending the shell immediately:

// Streaming
renderStream(res, callbacks, data, location);
// Blocking: Static rendering waits for everything
const { appHtml } = await renderSSR(data, location);

Always provide error handlers:

const { done } = renderStream(
res,
{
onError: (err) => {
logger.error("SSR error", err);
// Send fallback HTML or error page
res.statusCode = 500;
res.end("<html><body>Error loading page</body></html>");
},
},
dataLoader,
location
);

Prevent hanging requests:

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const { done } = renderStream(
res,
callbacks,
data,
location,
undefined,
{},
undefined,
controller.signal
);
await done.finally(() => clearTimeout(timeout));

4. Use Lazy Data Loading for Better Performance

Section titled “4. Use Lazy Data Loading for Better Performance”

Defer data fetching until needed:

// Lazy loading
renderStream(
res,
callbacks,
async () => {
return await fetchData();
},
location
);
// Blocks before streaming starts
const data = await fetchData();
renderStream(res, callbacks, data, location);

Keep head content generation pure and fast:

const { renderStream } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data, meta }) => {
return `
<title>${escapeHtml(data.pageTitle)}</title>
<meta name="description" content="${escapeHtml(data.description)}">
${meta.extraMeta || ""}
`;
// meta.extraMeta is your own static config HTML (trusted); every value derived
// from data or user input goes through escapeHtml
},
});

Use detailed logging during development:

const { renderStream } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data }) => `<title>${escapeHtml(data.pageTitle)}</title>`,
enableDebug: process.env.NODE_ENV === "development",
});

The CSR fallback (missing window.__INITIAL_DATA__) is automatic. onHydrationError OBSERVES client render failures - it does not trigger a fallback:

client.ts
hydrateApp({
appComponent: <App />,
onHydrationError: (err) => {
// Log/report the failure - nothing is remounted on your behalf
console.error("Client render failed", err);
},
});

Define clear interfaces between server and client:

types.ts
export interface AppData {
pageTitle: string;
user: User | null;
posts: Post[];
}
// server.ts
const { renderStream } = createRenderer<AppData>({...});
// client.ts
const data = useSSRStore<AppData>();

Section titled “Use @taujs/server (High-Level, Recommended for taujs Ecosystem)”

When:

  • Building a complete application with routing and data loading
  • Want declarative configuration via taujs.config.ts
  • Need microfrontend architecture support
  • Want automatic CSP, authentication, and middleware handling
  • Building within the taujs ecosystem

What you get:

  • Automatic SSR/streaming setup
  • Route-based data loading
  • Built-in security (CSP per route)
  • Service registry pattern
  • Development mode with HMR
  • Static asset management

Code example:

// taujs.config.ts - Declarative
export default defineConfig({
apps: [{
appId: 'root',
routes: [{
path: '/:id',
attr: {
data: async (params) => ({ ... }),
render: 'streaming',
},
}],
}],
});
// server.ts - Simple
await createServer({ fastify, config, serviceRegistry, clientRoot });

Use @taujs/react Directly (Low-Level, Framework-Agnostic)

Section titled “Use @taujs/react Directly (Low-Level, Framework-Agnostic)”

When:

  • Integrating with existing Express/Hono/etc. applications
  • Need fine-grained control over rendering
  • Building a custom SSR solution
  • Don’t need routing or data loading abstractions
  • Want minimal dependencies

What you get:

  • Direct control over rendering process
  • Framework agnostic on Node
  • Minimal abstraction
  • Explicit SSR and hydration APIs

Code example:

// Full control over rendering
import { createRenderer, escapeHtml } from "@taujs/react";
const { renderStream } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data }) => `<title>${escapeHtml(data.title)}</title>`,
});
app.get("*", (req, res) => {
const { done } = renderStream(
res,
{
onHead: (head) => {
/* custom logic */
},
onAllReady: (data) => {
/* custom logic */
},
},
data,
req.url
);
});

  • @taujs/server - Full-featured Fastify-based server with SSR orchestration

MIT


Issues and pull requests welcome at https://github.com/aoede3/taujs-react