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

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.
  • 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: any StreamOptions field (shellTimeoutMs, dataTimeoutMs) may also be overridden per call.

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

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, you can derive a typed route context from your config and feed it into createRenderer:

import { createRenderer, escapeHtml } from "@taujs/react";
import config from "../taujs.config";
import { AppBootstrap } from "./AppBootstrap";
import type { RouteContext } from "@taujs/server";
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, params, attr, data) and passes it into renderSSR / renderStream for you.

  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