Browse recipes

Connect SSR to Express or Fastify

Serve one isolated Valyrian.js render through the application's HTTP adapter.

Table of contents

APIs used

render
Serializes Valyrian.js views on the server.
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 choose the Express branch or the Fastify branch.
File
Create tsconfig.json, app.tsx, build-server.mjs and the selected server adapter file.
Run
Run the install, build and server commands for one branch, then request http://localhost:3000/profile.
Observable result
The selected adapter returns <main>SSR response for /profile</main> rendered by Valyrian.js.

1. Create tsconfig.json

Both adapter branches use the automatic Valyrian.js TSX runtime.

json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "valyrian.js",
    "skipLibCheck": true,
    "types": ["node"]
  }
}

2. Create app.tsx

Declare the functional component that each HTTP handler renders.

tsx
export function App({ requestPath }: { requestPath: string }) {
  return <main>SSR response for {requestPath}</main>;
}

3. Create build-server.mjs

Compile the selected adapter entry as Node ESM through inline while keeping installed server packages external.

js
import fs from "node:fs";
import path from "node:path";
import { inline } from "valyrian.js/node";

const entry = process.argv[2];
if (!entry) throw new Error("Pass a server .tsx entry");

const result = await inline(entry, {
  esbuild: {
    format: "esm",
    packages: "external",
    platform: "node",
  },
});
const output = path.basename(entry, ".tsx") + ".mjs";

fs.mkdirSync("./dist", { recursive: true });
fs.writeFileSync(path.join("./dist", output), result.raw);

4A. Create server.express.tsx

The Express 5 handler uses /{*splat} so the route includes / and every nested path. Each request opens one ServerStorage scope and renders App declared in TSX.

tsx
import express from "express";
import { render, ServerStorage } from "valyrian.js/node";
import { App } from "./app";

const server = express();

server.get("/{*splat}", (request, response, next) => {
  ServerStorage.run(() => {
    try {
      const html = render(<App requestPath={request.url} />);
      response.type("html").send(html);
    } catch (error) {
      next(error);
    }
  });
});

server.listen(3000);

4B. Build and run Express

Install the pinned Express 5 branch, compile its TSX entry and run the generated Node module.

bash
npm install valyrian.js@9.1.13 express@5.1.0
npm install --save-dev @types/express@5.0.3 @types/node@22.15.3
node build-server.mjs ./server.express.tsx
node dist/server.express.mjs

5A. Create server.fastify.tsx

The Fastify handler follows the same scope and renders the same TSX component.

tsx
import fastify from "fastify";
import { render, ServerStorage } from "valyrian.js/node";
import { App } from "./app";

const server = fastify();

server.get("/*", async (request, reply) =>
  ServerStorage.run(() => {
    const html = render(<App requestPath={request.url} />);
    return reply.type("text/html").send(html);
  }),
);

await server.listen({ port: 3000 });

5B. Build and run Fastify

Install the pinned Fastify 5 branch, compile its TSX entry and run the generated Node module.

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

Result

A request to /profile through either adapter receives this HTML. Add app.tsx, client-entry.tsx and the serialized state pattern from the hydration recipe when the response must hydrate.

html
<main>SSR response for /profile</main>

Continue