Browse learning paths

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

js
import 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

tsx
import { 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

tsx
import { 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);