6. SSR and hydration
Render a Taskboard instance for each request and hydrate the same application in the browser.
Table of contents
Stage 6. Add runtime SSR and hydration
- Starting state
- Complete Stage 5 so Taskboard has shared state, routes, asynchronous initial data and a local validated form.
- Applied change
- The Node.js server imports `createTaskboardApp` from the shared Taskboard module and creates an instance with isolated state for each request. It loads the initial tasks, resolves `request.url` through `router.go()` and renders the full TSX document inside `ServerStorage.run()`.
- Observable result
- TaskboardDocument serializes the request state into a body attribute so the browser can recreate the same application.
- Next step
- Continue with Stage 7 and add local offline delivery plus a worker update flow.
Run runtime SSR and observe hydration
Run the server and request both / and /about. Each response contains the route HTML and its own initial state. Then use Toggle details, About or the form to confirm that mountRouter hydrated the response and connected interaction.
package.json
json{
"name": "valyrian-taskboard",
"private": true,
"type": "module",
"scripts": {
"build": "node build.mjs",
"start": "node dist/server.mjs"
},
"dependencies": {
"valyrian.js": "9.1.13"
},
"devDependencies": {
"@types/node": "22.15.3"
}
}
build.mjs
jsimport fs from "node:fs";
import { inline } from "valyrian.js/node";
fs.mkdirSync("./dist", { recursive: true });
fs.mkdirSync("./public", { recursive: true });
const server = await inline("./src/server.tsx", {
esbuild: { format: "esm", packages: "external", platform: "node" },
});
const client = await inline("./src/client-entry.tsx");
fs.writeFileSync("./dist/server.mjs", server.raw);
fs.writeFileSync("./public/client-entry.js", client.raw);
src/server.tsx
tsximport { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { extname, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { render, ServerStorage } from "valyrian.js/node";
import type { Task } from "./state";
import { createTaskboardApp } from "./taskboard";
const publicRoot = fileURLToPath(new URL("../public", import.meta.url));
const contentTypes: Record<string, string> = {
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml; charset=utf-8",
".webmanifest": "application/manifest+json; charset=utf-8",
};
function TaskboardDocument({
routeHtml,
initialState,
}: {
routeHtml: string;
initialState: unknown;
}) {
return (
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Taskboard</title>
<script src="/client-entry.js" defer />
</head>
<body
data-initial-state={JSON.stringify(initialState)}
v-html={routeHtml}
/>
</html>
);
}
const server = createServer((request, response) => {
void (async () => {
try {
if (typeof request.url !== "string") {
response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Invalid request URL");
return;
}
const requestUrl = request.url;
const pathname = new URL(requestUrl, "http://localhost").pathname;
if (extname(pathname) !== "") {
const filePath = resolve(publicRoot, "." + pathname);
if (filePath.startsWith(publicRoot + sep) === false) {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not found");
return;
}
try {
const asset = await readFile(filePath);
response.writeHead(200, {
"Content-Type":
contentTypes[extname(filePath)] ?? "application/octet-stream",
});
response.end(asset);
} catch {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not found");
}
return;
}
await ServerStorage.run(async () => {
const tasks = JSON.parse(
await readFile(resolve(publicRoot, "tasks.json"), "utf8"),
) as Task[];
const app = createTaskboardApp();
app.store.replaceTasks(tasks);
const routeHtml = (await app.router.go(requestUrl)) ?? "";
const initialState = app.store.state;
const documentHtml =
"<!doctype html>" +
render(
<TaskboardDocument
routeHtml={routeHtml}
initialState={initialState}
/>,
);
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(documentHtml);
});
} catch {
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Server render failed");
}
})();
});
const portValue = process.env.PORT ?? "3000";
if (/^[1-9]\d*$/.test(portValue) === false || Number(portValue) > 65535) {
throw new Error("PORT must be an integer from 1 to 65535");
}
server.listen(Number(portValue), "127.0.0.1", () => {
console.log("Open http://127.0.0.1:" + portValue);
});
src/client-entry.tsx
tsximport { mountRouter } from "valyrian.js/router";
import { createTaskboardApp } from "./taskboard";
import type { TaskboardState } from "./state";
const encodedState = document.body.getAttribute("data-initial-state");
if (typeof encodedState !== "string") {
throw new Error("Initial state is missing");
}
const initialState = JSON.parse(encodedState) as TaskboardState;
const app = createTaskboardApp(initialState);
mountRouter("body", app.router);