Browse recipes

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.

tsx
export 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.

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

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

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

bash
npm install valyrian.js@9.1.13
npm install --save-dev @types/node@22.15.3
node build.mjs
node dist/server.mjs --verify

7. Run the application

Start the HTTP server and open the URL. The response contains a complete document, the App HTML and the client entry.

bash
node dist/server.mjs
# Open http://127.0.0.1:3000/users/42

8. 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>

Continue