Browse recipes

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.

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

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";

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.

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

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, serialized state 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></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>

Continue