Solid
@taujs/solid
Section titled “@taujs/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
Table of Contents
Section titled “Table of Contents”- Overview
- Installation
- Core Concepts
- Consuming SSR data
- API Reference
- The snapshot data bridge
- Client navigation
- Hydration and CSP
- Error handling and the sanitiser
- Known limitations
- Usage in the taujs ecosystem
- Standalone usage
- TypeScript support
- Best practices
Overview
Section titled “Overview”@taujs/solid provides:
- Streaming SSR with Solid’s
renderToStream, including deferred patches for app-owned async work - Static SSR with
renderToStringAsyncfor the request-firstssrstrategy - 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/rendererownsvite-plugin-solidinternally withssr: trueforced, 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/serveritself 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.
Installation
Section titled “Installation”npm install @taujs/solid solid-jsRecommended 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" }}Core Concepts
Section titled “Core Concepts”1. SSR data store
Section titled “1. SSR data store”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.
2. Renderer pattern
Section titled “2. Renderer pattern”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 },});3. Hydration strategy
Section titled “3. Hydration strategy”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 sharedrenderId - Falls back to a fresh CSR mount if no SSR data exists
4. Managed compilation
Section titled “4. Managed compilation”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.
Consuming SSR data
Section titled “Consuming SSR data”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 (the store)
Section titled “Route data (the store)”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>;API Reference
Section titled “API Reference”Server-side rendering
Section titled “Server-side rendering”createRenderer<T, R, H>(options)
Section titled “createRenderer<T, R, H>(options)”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 -
appComponentreceivesrouteContextasR,headContentreceivesdata: TandheadData?: 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 aJSX.Element.headContent: Generates the HTML head from{ data, headData?, meta, routeContext? }. It returns raw head HTML, so escape every interpolated value withescapeHtml. On both strategies the Solid adapter waits for route data before it starts rendering, sodatais committed whenheadContentruns. A route may also declareattr.head; the host resolves that dedicated dynamic head payload before rendering and passes it asheadData(typed by the third genericH).headDataisundefinedwhen the route declares noattr.headand when the head loader degraded (deadline expiry, or anoptionalloader failure), so always handle that member. The renderer appends Solid’s hydration bootstrap to your head result itself; yourheadContentmust not emit it.renderId: A stable namespace for Solid’s hydration markers and the serialised data. It must be the same value you pass tohydrateApp(see Client-side hydration). Keep it in one shared constant imported by both entries.streamOptions.shellTimeoutMs: Shell-readiness watchdog in ms (default10000).streamOptions.completionTimeoutMs: Bound on the wholerenderStreamlifecycle in ms (default30000). Every terminal clears it.ssrOptions.prerenderTimeoutMs: Bound on thessrstrategy’s single render promise in ms (default10000).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};Route-aware rendering with routeContext
Section titled “Route-aware rendering with routeContext”@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>`; },});renderSSR()
Section titled “renderSSR()”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);renderStream()
Section titled “renderStream()”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;Streaming semantics
Section titled “Streaming semantics”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__.
shellTimeoutMsbounds shell readiness;completionTimeoutMsbounds the whole lifecycle and is cleared by every terminal (normal completion, abort, or fatal).- App-owned
createResourcework under<Suspense>streams as Solid$dfdeferred 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.
Client-side hydration
Section titled “Client-side hydration”hydrateApp(options)
Section titled “hydrateApp(options)”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.
| Path | onStart | onSuccess | onHydrationError |
|---|---|---|---|
| Hydration succeeds | once, before native hydrate | once, after hydrate returns | never |
| Hydration throws | once | never | once |
| CSR fallback succeeds | never | once, after render establishes | never |
| CSR fallback throws | never | never | once |
| Root element missing | never | never | once |
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:
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 withhydrate(...)underrenderId, 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 noonStartfire - a CSR mount is not a hydration. A successful CSR mount still reportsonSuccess; a failed one reportsonHydrationError. - Automatically handles
DOMContentLoadedtiming. - 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>Data store
Section titled “Data store”createSSRStore<T>(seed)
Section titled “createSSRStore<T>(seed)”Creates a store for SSR data. Accepts synchronous data or a promise:
import { createSSRStore } from "@taujs/solid";
const store1 = createSSRStore({ a: 1 }); // committed immediatelyconst store2 = createSSRStore(Promise.resolve({ b: 2 })); // commits when it settlesThe 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.
useSSRStore<T>()
Section titled “useSSRStore<T>()”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".
Managed compilation
Section titled “Managed compilation”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.
solidRenderer({ project })
Section titled “solidRenderer({ project })”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: trueis 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: [ /* ... */ ], }, ],});Vite plugin
Section titled “Vite plugin”pluginSolid(options?)
Section titled “pluginSolid(options?)”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 })],});The snapshot data bridge
Section titled “The snapshot data bridge”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:
ssrroutes: the store is committed before render, sostore.data()is populated in the server HTML with no<Suspense>needed for route data.streamingroutes: route data is already in__INITIAL_DATA__, so a page that reads only route data emits no deferred patches. Application-ownedcreateResourcework under<Suspense>is what flows as$dfpatches (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.
Client navigation
Section titled “Client navigation”τ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.
Hydration and CSP
Section titled “Hydration and CSP”Whether a route hydrates is the host-resolved policy attr.hydrate (defaulting to true). Combined with the strategy, that yields four cells:
| Strategy | hydrate | Client entry | Solid bootstrap / _$HY | Deferred $df patches |
|---|---|---|---|---|
ssr | true | emitted | one nonced bootstrap | as produced |
ssr | false | none | none (noScripts) | none |
streaming | true | emitted | one nonced bootstrap | as produced |
streaming | false | none | retained | retained |
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.
Error handling and the sanitiser
Section titled “Error handling and the sanitiser”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.
Known limitations
Section titled “Known limitations”Usage in the taujs ecosystem
Section titled “Usage in the taujs ecosystem”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.
Standalone usage
Section titled “Standalone usage”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: truefor the server build). Use@taujs/solid/plugin’spluginSolidfor that, or the managedsolidRendererwhen running under τjs. appComponent/appare Solid components, and data is read from the store accessor.cspNonceis passed on theoptsobject, 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.TypeScript support
Section titled “TypeScript support”The package is written in TypeScript with full type definitions included.
Typed data contracts
Section titled “Typed data contracts”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>;}Route-derived types
Section titled “Route-derived types”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 itsattr.dataloader).HeadDataOf<typeof route>- whatheadContentreceives asheadDatafor a route. This is what makes theattr.head→headContentchain 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.
Logger types
Section titled “Logger types”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),};Best practices
Section titled “Best practices”- Know your two data kinds. Route data comes from the store (
store.data()) and is committed before render; app-owned async usescreateResourceunder<Suspense>. Only the latter streams. - Streaming carries application-owned async. Route data is resolved at the request boundary and travels in the snapshot;
createResourceunder<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. - Share one
renderId. Keep it in a single constant imported byentry-serverandentry-client; a drift between the two is a hydration bug. - Head data:
metafor static,attr.headfor dedicated dynamic head work. The Solid adapter waits for route data before it renders, sodatais committed whenheadContentruns.headDataremains optional because a route may omitattr.head, or its loader may degrade under the configured policy. Escape every interpolated value withescapeHtml. The renderer appends the hydration bootstrap itself; do not emit it fromheadContent. - Let the managed compiler own the plugin. Register
solidRenderer({ project })and pointprojectat a disjoint tsconfig; do not addvite-plugin-solidto your app yourself under τjs. - 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 throughonHydrationError. A hydration failure reports and stops rather than silently remounting. - 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. - 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.
Related packages
Section titled “Related packages”@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.
License
Section titled “License”MIT