Skip to content

React

React SSR (Server-Side Rendering) with Streaming Support

A lightweight, production-ready React SSR library with streaming capabilities, built for modern TypeScript applications. Designed as part of the taujs (τjs) ecosystem but fully standalone and runtime-agnostic.

τjs @taujs/react



@taujs/react provides a complete SSR solution for React applications with:

  • Streaming SSR with React 18+ renderToPipeableStream
  • Static SSR with renderToString for simpler use cases
  • 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
  • Runtime agnostic - works with Node.js, Fastify, Express, or any HTTP server

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;
status: "pending" | "success" | "error";
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>${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

Creates a renderer instance with streaming and static SSR capabilities.

Type Signature:

function createRenderer<T extends Record<string, unknown>>(options: {
appComponent: (props: { location: string }) => React.ReactElement;
headContent: (ctx: { data: T; meta: Record<string, unknown> }) => string;
enableDebug?: boolean;
logger?: LoggerLike;
streamOptions?: StreamOptions;
}): {
renderSSR: (
initialData: T,
location: string,
meta?: Record<string, unknown>,
signal?: AbortSignal,
opts?: { logger?: LoggerLike }
) => Promise<SSRResult>;
renderStream: (
writable: Writable,
callbacks: RenderCallbacks<T>,
initialData: T | Promise<T> | (() => Promise<T>),
location: string,
bootstrapModules?: string,
meta?: Record<string, unknown>,
cspNonce?: string,
signal?: AbortSignal,
opts?: StreamCallOptions
) => { abort: () => void; done: Promise<void> };
};

Parameters:

  • appComponent: Function that returns your root React component, receives { location: string }
  • headContent: Function that generates HTML head content, receives { data: T, meta: Record<string, unknown> }. Note: In streaming mode, data may not be resolved when shell is ready - guard optional fields or rely on meta.
  • enableDebug: Enable detailed logging (default: false)
  • logger: Custom logger implementation
  • streamOptions: Streaming configuration
    • shellTimeoutMs: Timeout for initial shell render (default: 10000)
    • useCork: Enable write batching with cork/uncork (default: true)

Returns:

Object with two rendering methods:

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

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

Example:

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

Streams the application with React 18+ streaming SSR.

Callbacks:

type RenderCallbacks<T> = {
onHead?: (head: string) => boolean | void; // Return false to wait for 'drain' before piping
onShellReady?: () => void;
onAllReady?: (data: T) => void;
onFinish?: (data: T) => void;
onError?: (err: unknown) => void;
};

Backpressure Handling:

  • If onHead() returns false, the renderer will wait for the writable stream’s 'drain' event before piping, respecting backpressure.
  • This is useful when the initial head write causes backpressure on slow connections.

Example:

const { renderStream } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data }) => `<title>${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;

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;
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)
  • logger: Custom logger implementation
  • dataKey: Key for initial data on window object (default: '__INITIAL_DATA__')
  • onHydrationError: Callback for hydration errors
  • onStart: Callback when hydration starts
  • onSuccess: Callback when hydration completes

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 hydrated successfully!");
},
});

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("Hydration failed, falling back to CSR:", err);
// Optionally report to error tracking service
},
onStart: () => {
console.log("Starting hydration...");
},
onSuccess: () => {
console.log("Hydration complete!");
// Initialise client-side only features
initialiseAnalytics();
},
});

Behavior:

  • If window[dataKey] exists: Performs SSR hydration with the provided data
  • If window[dataKey] is missing: Falls back to client-side rendering (CSR)
  • 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>

τjs provides framework-agnostic primitives for accessing route data on the client.

Fetch route data from the τjs data endpoint.

Type Signature:

function fetchRouteData<T extends RouteData = RouteData>(
pathname: string,
init?: RequestInit
): Promise<T>;

Example:

const data = await fetchRouteData("/dashboard");

Throws: RouteDataError on non-2xx responses.


Read SSR boot data from window.__INITIAL_DATA__ exactly once.

Type Signature:

function readInitialDataOnce<T extends RouteData = RouteData>(): T | null;

Returns: Data on first call, null on subsequent calls or if no SSR data exists.


Get the current browser path (pathname + search).

Type Signature:

function getCurrentPath(): string | null;

Returns: "/app/dashboard?tab=overview" or null on server.


Error class thrown by fetchRouteData() on failed requests.

Properties:

class RouteDataError extends Error {
readonly status: number; // HTTP status code
readonly statusText: string; // Status text
readonly code?: string; // Optional error code from server
readonly body?: unknown; // Full response body
}

Example:

try {
const data = await fetchRouteData("/dashboard");
} catch (err) {
if (err instanceof RouteDataError && err.status === 404) {
// Handle not found
}
}

See: Client-Side Data Fetching Guide for integration patterns and examples.


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(): Get current data (throws if pending)
  • getServerSnapshot(): Get data for server rendering (throws if pending)
  • setData(newData): Update the store
  • subscribe(callback): Subscribe to changes
  • status: Current status ('pending' | 'success' | 'error')
  • lastError: Last error if status is 'error'

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();
});

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
  • Uses useSyncExternalStore and useDeferredValue for React 18+ concurrent features
  • Automatically suspends if data is pending

Context provider for SSR store access.

Type Signature:

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

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()],
});

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',
},
}
  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.ts
import express from "express";
import { createRenderer } from "@taujs/react";
import { App } from "./App";
const app = express();
const { renderStream } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data }) => `<title>${data.pageTitle}</title>`,
});
app.get("*", async (req, res) => {
const data = await fetchPageData(req.path);
res.setHeader("Content-Type", "text/html; charset=utf-8");
// Write opening HTML
res.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<script>window.__INITIAL_DATA__=${JSON.stringify(data)};</script>
</head>
<body><div id="root">
`);
const { done } = renderStream(
res,
{
onAllReady: () => {
res.write(`</div></body></html>`);
res.end();
},
onError: (err) => {
console.error("Stream error:", err);
res.destroy();
},
},
data,
req.path,
"/assets/client.js"
);
await done;
});
app.listen(3000, () => {
console.log("Server listening on port 3000");
});
server.ts
import http from "node:http";
import { createRenderer } from "@taujs/react";
import { App } from "./App";
const { renderStream } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data }) => `<title>${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)};</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.ts
import { Hono } from "hono";
import { createRenderer } 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>${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)};</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 use cases or edge runtimes that don’t support streaming:

server.ts
import { createRenderer } from "@taujs/react";
import { App } from "./App";
const { renderSSR } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data }) => `<title>${data.pageTitle}</title>`,
});
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const data = { pageTitle: "Home", content: "Hello World" };
const { headContent, appHtml } = await renderSSR(data, url.pathname);
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
${headContent}
<script>window.__INITIAL_DATA__=${JSON.stringify(data)};</script>
</head>
<body>
<div id="root">${appHtml}</div>
<script type="module" src="/assets/client.js"></script>
</body>
</html>
`;
return new Response(html, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
},
};

Full Project Structure (with @taujs/server)

Section titled “Full Project Structure (with @taujs/server)”
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 } 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>${data?.title ?? meta.title ?? "My App"}</title>
${
data?.description
? `<meta name="description" content="${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.1.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 } 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>${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)};</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>${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>${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)};</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 } 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>${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)};
    </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>${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 },
    });
  • Use cork for write batching: Enabled by default for better performance

    streamOptions: {
    useCork: true;
    } // default
  • 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

  • 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>${data.pageTitle}</title>
<meta name="description" content="${data.description}">
${meta.extraMeta || ""}
`;
},
});

Use detailed logging during development:

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

Always handle the case where SSR data isn’t available:

client.ts
hydrateApp({
appComponent: <App />,
onHydrationError: (err) => {
// Log error but don't crash
console.error("Hydration failed, falling back to CSR", 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/runtime agnostic
  • Minimal abstraction
  • Explicit SSR and hydration APIs

Code example:

// Full control over rendering
import { createRenderer } from "@taujs/react";
const { renderStream } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data }) => `<title>${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