Render and hydrate one response
Carry each response state through a rendered body attribute and hydrate the same tree in the browser.
Table of contents
APIs used
- render
- Serializes Valyrian.js views on the server.
- mount
- Starts a Valyrian.js application on a DOM target.
- ServerStorage
- Provides request-scoped browser-like storage during server rendering.
- inline
- Bundles build input for server-side tooling.
Run this recipe
- Starting project
- Start with an empty Node.js project and install the pinned Valyrian.js and Node type packages shown in Step 6.
- File
- Create tsconfig.json, app.tsx, server.tsx, client-entry.tsx and build.mjs in the project root.
- Run
- Run node build.mjs and node dist/server.mjs --verify. Then run node dist/server.mjs and open http://127.0.0.1:3000/users/42 to use the hydrated button.
- Observable result
- The response contains Count: 1 and serialized state. Choosing Increment changes the hydrated view to Count: 2.
1. Create tsconfig.json
Use the automatic Valyrian.js TSX runtime for both server and client entries.
json{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "valyrian.js",
"skipLibCheck": true,
"types": ["node"]
}
}2. Create app.tsx
Declare the functional component once. The server and browser use the same state shape, and the button increments that state after hydration.
tsxexport type InitialState = { path: string; count: number };
export function App({ state }: { state: InitialState }) {
return (
<main>
<h1>Hello SSR at {state.path}</h1>
<button type="button" onclick={() => (state.count += 1)}>
Increment
</button>
<p>Count: {state.count}</p>
</main>
);
}3. Create server.tsx
Create a Node HTTP server that builds fresh state for every request, renders the TSX component inside ServerStorage.run and places that small state in a body attribute. The --verify mode requests both resources and closes the server after the finite check.
tsximport { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { render, ServerStorage } from "valyrian.js/node";
import { App, type InitialState } from "./app";
function AppShell(
{
initialState,
}: { initialState: InitialState; children?: unknown },
children: unknown,
) {
return (
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Valyrian SSR</title>
<script type="module" src="/client-entry.js"></script>
</head>
<body data-initial-state={JSON.stringify(initialState)}>{children}</body>
</html>
);
}
function renderResponse(path: string) {
const initialState = { path, count: 1 };
const document = render(
<AppShell initialState={initialState}>
<App state={initialState} />
</AppShell>,
);
return "<!doctype html>" + document;
}
const server = createServer((request, response) => {
void ServerStorage.run(async () => {
try {
if (request.url === "/client-entry.js") {
const client = await readFile("./public/client-entry.js");
response.writeHead(200, {
"Content-Type": "text/javascript; charset=utf-8",
});
response.end(client);
return;
}
const html = renderResponse(request.url || "/");
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(html);
} catch (error) {
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
response.end(
error instanceof Error ? error.message : "Server render failed",
);
}
});
});
if (process.argv.includes("--verify")) {
server.listen(0, "127.0.0.1", async () => {
let failure: unknown = null;
try {
const address = server.address();
if (typeof address !== "object" || address === null) {
throw new Error("Verification server has no address");
}
const origin = "http://127.0.0.1:" + address.port;
const htmlResponse = await fetch(origin + "/users/42");
const clientResponse = await fetch(origin + "/client-entry.js");
const html = await htmlResponse.text();
if (
htmlResponse.ok === false ||
clientResponse.ok === false ||
html.startsWith("<!doctype html>") === false ||
html.includes("data-initial-state=") === false ||
html.includes("Hello SSR at /users/42") === false ||
html.includes("Count: 1") === false
) {
throw new Error("SSR verification failed");
}
console.log("Verified SSR HTML and client-entry.js");
} catch (error) {
failure = error;
}
server.close(() => {
if (failure !== null) {
console.error(failure);
process.exitCode = 1;
}
});
});
} else {
server.listen(3000, "127.0.0.1", () => {
console.log("Open http://127.0.0.1:3000/users/42");
});
}4. Create client-entry.tsx
Read and parse the rendered body attribute, then mount the same App with that state on body. Valyrian.js hydrates the existing DOM and connects the increment button.
tsximport { mount } from "valyrian.js";
import { App, type InitialState } from "./app";
const encodedState = document.body.getAttribute("data-initial-state");
if (typeof encodedState !== "string") {
throw new Error("Initial state is missing");
}
const state = JSON.parse(encodedState) as InitialState;
mount("body", <App state={state} />);5. Create build.mjs
Use inline for both targets. The server bundle uses Node ESM and keeps installed packages external. The browser bundle includes the client entry.
jsimport fs from "node:fs";
import { inline } from "valyrian.js/node";
const server = await inline("./server.tsx", {
esbuild: {
format: "esm",
packages: "external",
platform: "node",
},
});
const client = await inline("./client-entry.tsx", { compact: true });
fs.mkdirSync("./dist", { recursive: true });
fs.mkdirSync("./public", { recursive: true });
fs.writeFileSync("./dist/server.mjs", server.raw);
fs.writeFileSync("./public/client-entry.js", client.raw);6. Build and verify
Install the pinned runtime, compile both TSX entries and run the finite HTTP verification. The process requests the HTML and client bundle, then closes its own server.
bashnpm install valyrian.js@9.1.13
npm install --save-dev @types/node@22.15.3
node build.mjs
node dist/server.mjs --verify7. Run the application
Start the HTTP server and open the URL. The response contains a complete document, the App HTML and the client entry.
bashnode dist/server.mjs
# Open http://127.0.0.1:3000/users/428. Result and interaction
The initial response shows Count: 1. After client-entry.js hydrates body, choosing Increment changes it to Count: 2.
html<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>Valyrian SSR</title><script type="module" src="/client-entry.js"></script></head><body data-initial-state="{"path":"/users/42","count":1}"><main><h1>Hello SSR at /users/42</h1><button type="button">Increment</button><p>Count: 1</p></main></body></html>