Run one application through SSR and hydration
Import the same `App` from the Node.js and browser entries. Node.js renders it with request state, and the browser mounts it with the serialized state to hydrate the existing DOM.
Table of contents
APIs used
- render
- Executes the shared Valyrian.js application in Node.js and returns its HTML.
- 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 `App` once in `app.tsx`. The Node.js and browser entries import this same application unchanged, and the button continues updating its 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 imports the existing `App`, builds fresh state for every request, executes the application inside `ServerStorage.run()` and places the serialized state in a body attribute.
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
Import the same `App` used by Node.js, read its serialized state and mount it 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>The server did not require a second application or a server-specific component tree. Both entries imported `App` from `app.tsx`. Node.js rendered it to HTML, and the browser mounted it to continue the application through hydration.