Render and hydrate one response
Carry one application state from the server response into the browser mount.
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 renders the TSX component inside ServerStorage.run, serializes request state and serves the browser entry. 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";
const serialize = (value: InitialState) =>
JSON.stringify(value)
.replace(/</g, "\\u003c")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
function AppShell(
{ initialState }: { initialState: InitialState; children?: unknown },
children: unknown,
) {
return (
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Valyrian SSR</title>
</head>
<body>
{children}
<script>{`window.__INITIAL_STATE__=${serialize(initialState)};`}</script>
<script type="module" src="/client-entry.js"></script>
</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("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
Mount the same App on body with the state embedded by server.tsx. Valyrian.js hydrates the existing DOM and connects the increment button.
tsximport { mount } from "valyrian.js";
import { App, type InitialState } from "./app";
declare global {
interface Window {
__INITIAL_STATE__?: InitialState;
}
}
const state = window.__INITIAL_STATE__ || { path: "/", count: 1 };
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, serialized state 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></head><body><main><h1>Hello SSR at /users/42</h1><button type="button">Increment</button><p>Count: 1</p></main><script>window.__INITIAL_STATE__={"path":"/users/42","count":1};</script><script type="module" src="/client-entry.js"></script></body></html>