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.
tsxexport 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.
jsimport 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.
tsximport 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.
bashnpm 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.mjs5A. Create server.fastify.tsx
The Fastify handler follows the same scope and renders the same TSX component.
tsximport 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.
bashnpm 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.mjsResult
A request to /profile through either adapter receives this HTML. Add app.tsx, client-entry.tsx and the rendered state attribute from the hydration recipe when the response must hydrate.
html<main>SSR response for /profile</main>