Skip to content

Solid

Solid SSR (Server-Side Rendering) with Streaming Support

A lightweight Solid SSR library with streaming and hydration support, built for modern TypeScript applications. It is designed for the τjs ecosystem but its runtime rendering and hydration APIs can also be used with a standalone Node HTTP server. Within τjs, the host resolves the route data and rendering policy before handing the response to Solid.

τjs @taujs/solid



@taujs/solid provides:

  • Streaming SSR with Solid’s renderToStream, including deferred patches for app-owned async work
  • Static SSR with renderToStringAsync for the request-first ssr strategy
  • Automatic hydration with built-in data synchronisation and a CSR fallback
  • Solid-native data store built on a signal Accessor, provided through Solid context
  • Managed compilation - @taujs/solid/renderer owns vite-plugin-solid internally with ssr: true forced, so an app never lists the compiler plugin itself
  • A fixed-identity error sanitiser - a post-shell resource rejection reaches the client redacted, never leaking server detail through the error channel
  • HTTP-framework-independent on Node, with a portable static-rendering path - the streaming API writes to a Node Writable, so it can be integrated with Fastify, Express or another Node HTTP server. Static SSR (renderSSR) returns strings and does not use the streaming sink. Worker/edge deployment still depends on the target selecting Solid’s server-compatible export conditions and on the surrounding application being portable; @taujs/server itself is Node-based, and this package does not expose a Web Streams renderer.

@taujs/solid conforms to the same private host-facing render contract as @taujs/react and @taujs/vue, and uses the same hydration-beacon vocabulary, but it does not pretend that the frameworks have identical rendering semantics. Route data flows through a signal store you read like any other Solid accessor, and app-owned async uses Solid’s own createResource + <Suspense> rather than a bespoke composable. The public author surface is deliberately small; Solid-specific completion, error and hydration behaviour is called out in place.


Terminal window
npm install @taujs/solid solid-js

Recommended dependency ranges:

{
"dependencies": {
"@taujs/solid": "latest",
"solid-js": "^1.9.0"
},
"devDependencies": {
"typescript": "^5.5.0",
"vite": "^7.0.0",
"vite-plugin-solid": "^2.11.0"
}
}

The SSRStore is a type-safe container that synchronises data between server and client. It is deliberately minimal - a Solid signal Accessor plus a setter - so components read it exactly the way they read any other Solid signal:

import type { Accessor } from "solid-js";
type SSRStore<T> = {
/** Read the current data. A Solid Accessor, so reads are reactive. */
data: Accessor<T>;
/** Replace the data (used by the client seed and by recovery paths). */
setData(value: T): void;
};

There is no status, ready or getSnapshot surface here as in @taujs/vue - Solid’s own reactivity and <Suspense> cover those needs. For route data on an ssr route the store is already committed before your component renders, so store.data() returns the value synchronously.

createRenderer builds a reusable renderer from your root component and configuration, returning both a static and a streaming render method:

const { renderSSR, renderStream } = createRenderer({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data }) => `<title>${escapeHtml(String(data.title))}</title>`,
renderId: "my-app",
streamOptions: { shellTimeoutMs: 10000, completionTimeoutMs: 30000 },
});

Client-side hydration automatically:

  • Detects SSR data from window.__INITIAL_DATA__
  • Creates a synchronised store and provides it to your tree
  • Hydrates the Solid tree with hydrate(...) under the shared renderId
  • Falls back to a fresh CSR mount if no SSR data exists

Solid is a compiled framework: its JSX must be transformed by vite-plugin-solid, and SSR needs that transform in ssr: true mode. Rather than making every app wire the plugin (and risk the client and server halves disagreeing), @taujs/solid/renderer exposes solidRenderer({ project }), which owns the plugin internally with ssr: true forced and scopes it to a disjoint tsconfig. You register the renderer, not a plugin. See Managed compilation.


There are two distinct kinds of data, and they use different mechanisms. Getting this split right is the single most important thing to understand about @taujs/solid.

Route data is the payload the server resolves for a route (attr.data). It reaches your component through the store the renderer provides. On the server the snapshot bridge commits it before the render begins; on the client hydrateApp seeds it from window.__INITIAL_DATA__. Either way you read it like any other accessor:

import { Show } from "solid-js";
import { useSSRStore } from "@taujs/solid";
type RouteData = { title?: string; message?: string; items?: string[] };
function Page() {
const store = useSSRStore<RouteData>();
return (
<main>
<Show when={store.data().message} fallback={<p>No route data.</p>}>
<p>{store.data().message}</p>
</Show>
</main>
);
}

Because route data is already committed on the ssr strategy, no <Suspense> is needed to read it - store.data() is populated synchronously in the server-rendered HTML.

App-owned async (Suspense + createResource)

Section titled “App-owned async (Suspense + createResource)”

App-owned async is work a component starts itself - a createResource under <Suspense>, such as a widget fetching its own data. Solid streams that work as a deferred ($df) patch once it settles, and τjs manages its transport: the shell and completion deadlines, cancellation and stream completion all bound it. The work itself is application-owned and UI-local - τjs governs its delivery, not its meaning; it is not part of the route’s data contract or the route graph. Keep governed request data in the route loader, and reach for createResource for genuinely component-local async:

import { createResource, Suspense } from "solid-js";
function DeferredNote() {
const [note] = createResource(async () => {
const res = await fetch("/api/note");
return (await res.json()).text as string;
});
return <p>{note()}</p>;
}
// ...rendered under a boundary:
<Suspense fallback={<p>loading…</p>}>
<DeferredNote />
</Suspense>;

Creates a renderer instance with streaming and static SSR capabilities.

Type signature:

import type { JSX } from "solid-js";
function createRenderer<
T extends Record<string, unknown> = Record<string, unknown>,
R = unknown,
H extends Record<string, unknown> = Record<string, unknown>
>(options: {
appComponent: (props: { location: string; routeContext?: R }) => JSX.Element;
headContent: (ctx: HeadContext<T, R, H>) => string;
renderId?: string;
streamOptions?: StreamOptions;
ssrOptions?: SSROptions;
logger?: SolidLogger;
}): {
// 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: (
initialDataResolved: Record<string, unknown>,
location: string,
meta?: Record<string, unknown>,
signal?: AbortSignal,
opts?: RenderOptions
) => Promise<{ headContent: string; appHtml: string }>;
renderStream: (
sink: Writable,
callbacks: RenderCallbacks<T>,
initialData:
| Record<string, unknown>
| Promise<Record<string, unknown>>
| (() => Promise<Record<string, unknown>>),
location: string,
bootstrapModules?: string,
meta?: Record<string, unknown>,
signal?: AbortSignal,
opts?: RenderOptions
) => { 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.

Parameters:

  • appComponent: Your Solid root. A function that receives { location, routeContext? } and returns a JSX.Element.
  • headContent: Generates the HTML head from { data, headData?, meta, routeContext? }. It returns raw head HTML, so escape every interpolated value with escapeHtml. On both strategies the Solid adapter waits for route data before it starts rendering, so data is committed when headContent runs. A route may also declare attr.head; the host resolves that dedicated dynamic head payload before rendering and passes it as headData (typed by the third generic H). headData is undefined when the route declares no attr.head and when the head loader degraded (deadline expiry, or an optional loader failure), so always handle that member. The renderer appends Solid’s hydration bootstrap to your head result itself; your headContent must not emit it.
  • renderId: A stable namespace for Solid’s hydration markers and the serialised data. It must be the same value you pass to hydrateApp (see Client-side hydration). Keep it in one shared constant imported by both entries.
  • streamOptions.shellTimeoutMs: Shell-readiness watchdog in ms (default 10000).
  • streamOptions.completionTimeoutMs: Bound on the whole renderStream lifecycle in ms (default 30000). Every terminal clears it.
  • ssrOptions.prerenderTimeoutMs: Bound on the ssr strategy’s single render promise in ms (default 10000).
  • logger: Custom logger implementation (SolidLogger).

Returns an object with two rendering methods: renderSSR (static, returns { headContent, appHtml }) and renderStream (streaming, pipes to a writable).

The HeadContext, RenderCallbacks, RenderOptions, StreamOptions and SSROptions types:

type HeadContext<
T extends Record<string, unknown> = Record<string, unknown>,
R = unknown,
H extends Record<string, unknown> = Record<string, unknown>
> = {
data: T;
headData?: H; // the route's attr.head payload, resolved by the host before the render
meta: Record<string, unknown>;
routeContext?: R;
};
type RenderCallbacks<T = unknown> = {
/** Receives the head string; the server writes it into <head>. Return value is ignored. */
onHead?: (headContent: string) => void;
onShellReady?: () => void;
onAllReady?: (initialData: T) => void;
onError?: (error: unknown) => void;
/** Present for contract compatibility with @taujs/react. Solid v1 never calls it. */
onRenderError?: (info: RenderErrorInfo) => void;
};
type RenderOptions = {
logger?: SolidLogger;
routeContext?: unknown;
headData?: Record<string, unknown>;
cspNonce?: string;
shouldHydrate?: boolean;
};
type StreamOptions = {
shellTimeoutMs?: number; // default 10000
completionTimeoutMs?: number; // default 30000
};
type SSROptions = {
prerenderTimeoutMs?: number; // default 10000
};

@taujs/solid knows nothing about your router, but it can carry route information from the server into both your Solid tree (appComponent) and your head generator (headContent) through the generic R and a routeContext value passed per request on the final opts argument:

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

Renders the application to a string for the ssr strategy. The host resolves route data before calling it, so the store is already committed when your component reads it. Returns { headContent, appHtml }.

const { renderSSR } = createRenderer<{ msg: string }>({
appComponent: () => <App />,
headContent: ({ data }) => `<title>${escapeHtml(data?.msg ?? "no-data")}</title>`,
});
const { headContent, appHtml } = await renderSSR(
{ msg: "hello" },
"/",
{ requestId: "123" } // meta
);

Streams the application with Solid’s renderToStream. When bootstrapModules is provided, the renderer emits the client bootstrap <script type="module" … async> tag before ending the stream, so a streamed route hydrates. A cspNonce on opts is applied to that tag.

const { done, abort } = renderStream(
res, // Node.js Writable
{
onHead: (head) => {
// Server writes `head` into <head>; the renderer never writes head bytes.
},
onShellReady: () => {
// Fires when the shell is ready.
},
onAllReady: (data) => {
// Fires once BOTH latches close: route data is ready AND Solid has completed.
},
onError: (err) => {
console.error("Stream error:", err);
},
},
{ msg: "hello" }, // initialData: a record, a promise of one, or a lazy factory
"/streaming", // location
"/assets/entry-client.js", // bootstrapModules
{ requestId: "123" }, // meta
undefined, // signal
{ cspNonce: "nonce-abc123" } // opts (RenderOptions)
);
await done;

onAllReady is two-latch: it fires once and only once, after the route data is ready and Solid has finished rendering. A fatal, incomplete delivery never calls onAllReady, so it never seeds a truncated document. A pre-shell fatal also never calls onShellReady; a post-shell fatal necessarily occurs after onShellReady has already reported that the shell was committed. The resolved route data is delivered to onAllReady and serialised into window.__INITIAL_DATA__.

  • shellTimeoutMs bounds shell readiness; completionTimeoutMs bounds the whole lifecycle and is cleared by every terminal (normal completion, abort, or fatal).
  • App-owned createResource work under <Suspense> streams as Solid $df deferred patches once it settles; route data does not (see The snapshot data bridge).

Cancellation and lifecycle. renderStream accepts an AbortSignal (a triggered signal is a benign cancel) and returns { abort, done }: abort() performs a manual benign abort, and done resolves on normal completion or rejects on a fatal error. Client disconnects are classified benign and never surface as fatal errors. Every terminal releases the renderer’s request-scoped state exactly once.


Hydrates a server-rendered Solid application on the client, or mounts a fresh CSR app if no SSR data is present.

Type signature:

import type { JSX } from "solid-js";
import type { SolidLogger } from "@taujs/solid";
function hydrateApp(options: {
/** Your root, receiving { location }. */
app: (props: { location: string }) => JSX.Element;
/** Must match the renderId the SERVER rendered with. */
renderId?: string;
rootElementId?: string; // default 'root'
/** Receives hydration lifecycle logs (see below). */
logger?: SolidLogger;
/** Gate verbose lifecycle logs. Warnings and errors are never gated. Default false. */
enableDebug?: boolean;
/** Observes the start of hydration (hydrate path only). */
onStart?: () => void;
/** Observes successful root establishment (hydrate or CSR). */
onSuccess?: () => void;
/** Observes a failed root establishment. */
onHydrationError?: (error: unknown) => void;
}): void;

Lifecycle callbacks. The three observers are advisory - a throw from any of them is logged and swallowed; it never becomes an onHydrationError, manufactures a beacon, or tears down the root.

PathonStartonSuccessonHydrationError
Hydration succeedsonce, before native hydrateonce, after hydrate returnsnever
Hydration throwsonceneveronce
CSR fallback succeedsneveronce, after render establishesnever
CSR fallback throwsneverneveronce
Root element missingneverneveronce

onSuccess means Solid returned from hydrate(...) (or render(...) on CSR) without a bootstrap failure - the root is established. It does not mean every resource or <Suspense> boundary settled. Exactly one of onSuccess | onHydrationError settles per call, each at most once.

This is the same lifecycle contract @taujs/react and @taujs/vue expose; the option objects are not identical (Solid’s renderId, React’s identifierPrefix, Vue’s setupApp and its App-instance callback argument remain framework-native), and Solid deliberately has no dataKey - window.__INITIAL_DATA__ is the one snapshot authority.

Logging. With enableDebug: true the start/success/CSR lifecycle is logged through logger; warnings and errors are logged regardless of enableDebug. With no logger, warnings and errors fall back to console.warn / console.error (verbose stays off unless enableDebug). The route-data snapshot is never logged.

Example:

entry-client.tsx
import { hydrateApp } from "@taujs/solid";
import { App } from "./App";
import { RENDER_ID } from "./renderId";
hydrateApp({
app: ({ location }) => <App location={location} />,
renderId: RENDER_ID,
rootElementId: "root",
onHydrationError: (error) => {
console.error("Hydration failed:", error);
},
});

Behaviour:

  • If window.__INITIAL_DATA__ exists: hydrates the SSR markup with hydrate(...) under renderId, seeding the store from the payload.
  • If window.__INITIAL_DATA__ is missing: clears any stale markup and mounts a fresh CSR app. No hydration beacon events and no onStart fire - a CSR mount is not a hydration. A successful CSR mount still reports onSuccess; a failed one reports onHydrationError.
  • Automatically handles DOMContentLoaded timing.
  • Provides the store to your tree on both the hydrate and CSR paths.

Devtools beacon (dev-only). On the hydrate path, hydrateApp emits hydration:start, hydration:success and hydration:error to window.__TAUJS_DEVTOOLS_HOOK__, each before the matching user callback. The hook is only ever set by the server-injected dev script, is absent in production, and emission can never throw into hydration. The CSR-fallback path emits no beacons (a CSR mount is not a hydration), though it still reports onSuccess / onHydrationError.

HTML template requirements. Your server-rendered HTML must include the root element, the initial-data script and the client bundle. With @taujs/server these are assembled for you; standalone you provide them yourself:

<main id="root"><!-- SSR content here --></main>
<script>
window.__INITIAL_DATA__ = { message: "Hello" };
</script>
<script type="module" src="/assets/entry-client.js"></script>

Creates a store for SSR data. Accepts synchronous data or a promise:

import { createSSRStore } from "@taujs/solid";
const store1 = createSSRStore({ a: 1 }); // committed immediately
const store2 = createSSRStore(Promise.resolve({ b: 2 })); // commits when it settles

The lazy () => Promise<T> form belongs to the host-facing renderStream input, not to this public store factory. The adapter invokes that thunk exactly once inside its guarded render lifecycle and passes the resulting value or promise to createSSRStore.

Inside components you rarely call createSSRStore directly - @taujs/server and hydrateApp create and provide the store for you. You consume it with useSSRStore.

Returns the store (SSRStore<T>), whose data is a Solid Accessor<T>:

import { useSSRStore } from "@taujs/solid";
function Component() {
const store = useSSRStore<{ user: string }>();
return <p>{store.data().user}</p>; // reactive read
}

It must be called inside a component rendered by the τjs Solid renderer; otherwise it throws "taujs: useSSRStore must be called inside a component rendered by the τjs Solid renderer".


Solid’s JSX must be compiled by vite-plugin-solid, and SSR needs that transform in ssr: true mode. @taujs/solid/renderer handles this so your app never wires the plugin - and so the client and server halves can never disagree on transform mode.

type SolidRendererOptions = { project: string };
function solidRenderer(opts: SolidRendererOptions): TaujsRendererContribution;
  • project: path to a disjoint tsconfig that claims only your Solid client TSX. Scoping the compiler to that project keeps it from claiming the server tree. It is the entire option surface - ssr: true is forced internally and there is no transform-mode option to get wrong.

You register it per app in taujs.config.ts under renderer (not in a plugins array):

import { defineConfig } from "@taujs/server/config";
import { solidRenderer } from "@taujs/solid/renderer";
export default defineConfig({
apps: [
{
appId: "main",
entryPoint: "",
renderer: solidRenderer({ project: "./tsconfig.solid.json" }),
routes: [
/* ... */
],
},
],
});

A thin passthrough over vite-plugin-solid, for standalone Vite setups or manual wiring. Options are forwarded verbatim. Within the τjs ecosystem prefer solidRenderer (above), which manages the plugin for you.

function pluginSolid(opts?: Parameters<typeof solid>[0]): PluginOption;
// vite.config.ts (standalone)
import { defineConfig } from "vite";
import { pluginSolid } from "@taujs/solid/plugin";
export default defineConfig({
plugins: [pluginSolid({ ssr: true })],
});

A τjs response renders one selected app and root: the host matches the URL to a single app by appId and loads that app’s renderer for the response. So window.__INITIAL_DATA__ is that response’s single route-data authority - one root hydrates one payload, and no second app’s data shares the document.

The server commits the resolved route data into the store before rendering and serialises the same payload into __INITIAL_DATA__; the client seeds the store from it during hydration. Route data therefore never travels through Solid’s $df deferred-patch channel, on any strategy.

The practical consequences:

  • ssr routes: the store is committed before render, so store.data() is populated in the server HTML with no <Suspense> needed for route data.
  • streaming routes: route data is already in __INITIAL_DATA__, so a page that reads only route data emits no deferred patches. Application-owned createResource work under <Suspense> is what flows as $df patches (see App-owned async).
  • The client never re-runs your route loader. It hydrates from the snapshot; there is exactly one route-data seed per response.

τjs governs the response for a URL. What a subsequent navigation does depends on whether it crosses a τjs app boundary:

  • Between τjs apps (a different appId): a full-document navigation. The browser requests the new URL, τjs matches it, resolves its data and policy, and returns a new governed response - the same request-to-response path as the first load. See Navigation Between Apps.
  • Within a single app shell: the application’s own client-side router (Solid Router, TanStack Router, or a custom router) owns navigation and any subsequent data fetching. τjs delivers the governed first response; the shell drives client-side transitions from there. See App Shell Architecture.

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


Whether a route hydrates is the host-resolved policy attr.hydrate (defaulting to true). Combined with the strategy, that yields four cells:

StrategyhydrateClient entrySolid bootstrap / _$HYDeferred $df patches
ssrtrueemittedone nonced bootstrapas produced
ssrfalsenonenone (noScripts)none
streamingtrueemittedone nonced bootstrapas produced
streamingfalsenoneretainedretained

The streaming + hydrate: false cell is the subtle one: no application hydration runs (no client entry), but the Solid bootstrap and patch machinery are retained, because the deferred $df patches still need _$HY to exist to attach to. The ssr + hydrate: false cell is fully static: noScripts means no $R, _$HY or $df at all.

CSP. When you pass cspNonce (via RenderOptions.cspNonce), the renderer stamps it on the hydration bootstrap and every resource script it emits. With @taujs/server the per-request nonce is supplied for you and every inline script it emits carries it, so an enforced script-src policy with no 'unsafe-inline' passes with zero violations.


A route loader that rejects before the shell is a pre-shell fatal: it aborts the render, routes the failure to onError, and never seeds a truncated document.

An app-owned resource that rejects after the shell is different - Solid renders it into the client ErrorBoundary. @taujs/solid sanitises that error before it crosses to the client: the value delivered through the error channel has a fixed identity - name Error, message [redacted] - and carries no message, stack, cause or custom property from the server. A secret injected into any of those fields on the server never reaches the browser.

import { ErrorBoundary, Suspense } from "solid-js";
<ErrorBoundary fallback={(error) => <p>{`${error.name}: ${error.message}`}</p>}>
<Suspense fallback={<p>loading…</p>}>
<RejectingNote />
</Suspense>
</ErrorBoundary>;
// The boundary renders "Error: [redacted]" - never the server-side detail.

The sanitiser is a fixed-identity redactor only. It generates no callbacks, logging, tracing or provenance detection - confidentiality is the whole of its job.

When @taujs/server assembles the document, __proto__ in a route-data payload is handled safely end to end: it arrives as an own property of __INITIAL_DATA__ with Object.prototype left untouched, so a malicious key cannot pollute the global prototype. Standalone integrations own their snapshot serialisation and must use an equivalently script-safe, prototype-safe encoding rather than interpolating unchecked JSON into HTML.



With @taujs/server, you don’t call the render functions directly. You define rendering once in entry-server, hydration once in entry-client, register solidRenderer per app, and declare routes and data in taujs.config.ts; the server orchestrates everything.

taujs.config.ts - register solidRenderer({ project }) per app and declare each route’s strategy:

import { defineConfig } from "@taujs/server/config";
import { solidRenderer } from "@taujs/solid/renderer";
export default defineConfig({
server: { port: 5173, host: "localhost", hmrPort: 5174 },
apps: [
{
appId: "main",
entryPoint: "",
renderer: solidRenderer({ project: "./tsconfig.solid.json" }),
routes: [
{
path: "/",
attr: {
render: "ssr",
hydrate: true,
// Standard SSR: data resolves before the first byte, so the store is
// already committed when the component reads it.
data: async (params, ctx) => ctx.call("content", "home"),
},
},
{
path: "/streaming",
attr: {
render: "streaming",
hydrate: true,
data: async (params, ctx) => ctx.call("content", "streaming"),
// meta recommended for streaming routes for SEO/social and render timing
meta: {
title: "τjs - Streaming",
description: "Streaming SSR route (deferred patches complete after the shell).",
},
},
},
],
},
],
});

renderId.ts - one shared constant, imported by both entries so the two can never drift:

export const RENDER_ID = "main";

entry-server.tsx - the renderer @taujs/server calls internally:

import { createRenderer, escapeHtml } from "@taujs/solid";
import { App, type RouteData } from "./App";
import { RENDER_ID } from "./renderId";
type HeadData = { ogTitle?: string; ogDescription?: string };
export const { renderSSR, renderStream } = createRenderer<RouteData, unknown, HeadData>({
appComponent: ({ location }) => <App location={location} />,
renderId: RENDER_ID,
headContent: ({ data, headData, meta }) => `
<title>${escapeHtml(headData?.ogTitle ?? data?.title ?? (meta as { title?: string })?.title ?? "τjs Solid")}</title>
${headData?.ogDescription ? `<meta name="description" content="${escapeHtml(headData.ogDescription)}">` : ""}
`,
});

entry-client.tsx - hydration entry, under the SAME renderId:

import { hydrateApp } from "@taujs/solid";
import { App } from "./App";
import { RENDER_ID } from "./renderId";
hydrateApp({
app: ({ location }) => <App location={location} />,
renderId: RENDER_ID,
rootElementId: "root",
});

App.tsx - the root receives location from the server and reads route data from the store:

import { Show, Suspense, createResource } from "solid-js";
import { useSSRStore } from "@taujs/solid";
export type RouteData = { title?: string; message?: string; items?: string[] };
function DeferredNote() {
const [note] = createResource(async () => {
const res = await fetch("/api/note");
return (await res.json()).text as string;
});
return <p>{note()}</p>;
}
export function App(props: { location: string }) {
const store = useSSRStore<RouteData>();
return (
<main id="app">
<p>{props.location}</p>
<Show when={store.data().message} fallback={<p>No route data.</p>}>
<p>{store.data().message}</p>
</Show>
<Suspense fallback={<p>loading…</p>}>
<DeferredNote />
</Suspense>
</main>
);
}

That is the complete request flow: the server matches a route, runs its data loader, calls renderSSR / renderStream, assembles the HTML with your head and the __INITIAL_DATA__ payload, and your entry-client.tsx hydrates it. You never inject __INITIAL_DATA__ or manage the template yourself.


Outside @taujs/server, createRenderer and hydrateApp remain usable as standalone Solid primitives; they are independent of τjs’s Fastify host, not independent of Solid. The surrounding Node server plumbing - Express, Hono on Node, native Node HTTP, backpressure, CSP nonces and AbortSignal - follows the same integration shape as @taujs/react; see the React reference for those HTTP-server walkthroughs. The Solid-specific differences are:

  • You must compile the client and server with vite-plugin-solid (ssr: true for the server build). Use @taujs/solid/plugin’s pluginSolid for that, or the managed solidRenderer when running under τjs.
  • appComponent / app are Solid components, and data is read from the store accessor.
  • cspNonce is passed on the opts object, not positionally.

Static SSR (no streaming) returns the head and app HTML. This is the portable rendering path for simple integrations and for worker/edge targets that select Solid’s server-compatible exports; assemble the document and inject window.__INITIAL_DATA__ yourself with a script-safe serialiser:

import { createRenderer, escapeHtml } from "@taujs/solid";
import { App } from "./App";
const { renderSSR } = createRenderer<{ message: string }>({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data }) => `<title>${escapeHtml(data?.message ?? "App")}</title>`,
});
const data = { message: "Hello World" };
const { headContent, appHtml } = await renderSSR(data, "/");
// Build: <head>${headContent}</head>, <main id="root">${appHtml}</main>,
// a <script>window.__INITIAL_DATA__=…</script>, and the entry-client module tag.

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

Type the payload once and thread it through the renderer and the store:

interface PageData {
message: string;
user: { id: string; name: string };
}
const { renderSSR, renderStream } = createRenderer<PageData>({
appComponent: ({ location }) => <App location={location} />,
headContent: ({ data }) => `<title>${escapeHtml(data?.message ?? "App")}</title>`,
});
import { useSSRStore } from "@taujs/solid";
function Component() {
const store = useSSRStore<PageData>(); // store.data() is PageData
return <p>{store.data().user.name}</p>;
}

You do not have to hand-repeat a payload shape. @taujs/server/config exports types that derive from your taujs.config.ts route table:

  • RouteData<typeof config, "/path"> - the resolved data type of the route with that path (the awaited return of its attr.data loader).
  • HeadDataOf<typeof route> - what headContent receives as headData for a route. This is what makes the attr.headheadContent chain typed end to end (RFC 0004, a signed type gate).
  • RouteContext<...> - the union of { appId, path, data, attr } across the config’s routes.
import type { RouteData } from "@taujs/server/config";
import config from "./taujs.config";
// Derived from the route loader, not hand-written:
type HomeData = RouteData<typeof config, "/">;
// In the Solid entry / component:
const store = useSSRStore<HomeData>();

The chain is not fully automatic: useSSRStore<T>() still takes T as an argument, so it is an assertion at that call site. But deriving T from the route contract - rather than duplicating the shape in a second place - keeps the store type honest against the loader, and the head-data chain (HeadDataOf) is inferred end to end.

ServerLogger, UILogger and the union SolidLogger describe the accepted logger shapes; pass one to createRenderer (or per call via opts.logger):

import type { ServerLogger } from "@taujs/solid";
const logger: ServerLogger = {
info: (meta, message) => console.log(message, meta),
warn: (meta, message) => console.warn(message, meta),
error: (meta, message) => console.error(message, meta),
};

  1. Know your two data kinds. Route data comes from the store (store.data()) and is committed before render; app-owned async uses createResource under <Suspense>. Only the latter streams.
  2. Streaming carries application-owned async. Route data is resolved at the request boundary and travels in the snapshot; createResource under <Suspense> is component-owned work that streams as deferred patches. Use it for UI-local async, not to move governed route data off the request boundary.
  3. Share one renderId. Keep it in a single constant imported by entry-server and entry-client; a drift between the two is a hydration bug.
  4. Head data: meta for static, attr.head for dedicated dynamic head work. The Solid adapter waits for route data before it renders, so data is committed when headContent runs. headData remains optional because a route may omit attr.head, or its loader may degrade under the configured policy. Escape every interpolated value with escapeHtml. The renderer appends the hydration bootstrap itself; do not emit it from headContent.
  5. Let the managed compiler own the plugin. Register solidRenderer({ project }) and point project at a disjoint tsconfig; do not add vite-plugin-solid to your app yourself under τjs.
  6. Handle CSR fallback. Provide onHydrationError; remember the CSR path (no __INITIAL_DATA__) emits no hydration beacon by design, while a failed CSR mount still reports through onHydrationError. A hydration failure reports and stops rather than silently remounting.
  7. Keep secrets out of resource errors regardless. The sanitiser redacts post-shell rejections to a fixed identity, but treat that as defence in depth, not licence to put secrets in an error cause.
  8. Know the navigation model. A cross-app navigation is a full document load and a new governed response; within an app shell the app’s own router owns client navigation and subsequent data, which you fetch from your own /api/* endpoints (for example with TanStack Solid Query). See Client navigation.

  • @taujs/server - Fastify-based server with SSR orchestration, routing and data loading.
  • @taujs/react - the React renderer; conforms to the same host-facing renderer contract with React-specific streaming semantics.
  • @taujs/vue - the Vue renderer; conforms to the same host-facing renderer contract with Vue-specific streaming semantics.

MIT