Framework Recipe: Plain Node (express / Fastify)
The simplest SSR host: one request, one server render, one HTML response. This is the reference recipe every other host adapts.
Source of truth: examples/node-ssr/server.tsx in the SDK repo. CI runs this example on every PR. Copy the pattern verbatim. If this doc drifts from the example, trust the example.
Read Framework recipes: SSR integration first for the three-call contract and five universal rules.
Do It With a Skill
configure-csr-vs-ssr picks and wires the render path for this framework. Run it before following the recipe by hand, or use the recipe to check what it wrote.
curl -fsSL https://studio-documentation.contentstackapps.com/install.sh | sh
Prerequisites
- Studio installed and @contentstack/studio-react in dependencies (see install-studio).
- All components registered at module scope (both built-ins and custom). See Rule 2 in the shared concepts page.
- A catch-all route pattern in your HTTP layer (Rule 4).
- Node 20+ (React 19 needs it for async server components).
The Recipe
One file. The three calls (data, render, styles) plus metadata injection.
// server.tsx
import express from "express";
import { renderToString } from "react-dom/server";
import {
StudioComponent,
getSSRStyleTags,
getCompositionMetadata,
renderMetadataToHtml,
} from "@contentstack/studio-react";
import { sdk } from "./studio.server";
// Registry: import every registration module at server startup.
// Rule 2 — a root import is required so built-ins auto-register.
import "./registry";
const app = express();
// Rule 4 — one catch-all route handles every visitor URL.
app.get("*", async (req, res) => {
try {
// 1) Data — server-side fetch, no hooks.
const specOptions = await sdk.fetchCompositionData(
{ url: req.path, searchQuery: new URLSearchParams(req.query as Record<string, string>) },
{ locale: "en-us" },
);
if (!specOptions?.spec) {
res.status(404).send("Not found");
return;
}
// 2) Render — pure React, produces the composition's DOM.
const bodyHtml = renderToString(<StudioComponent specOptions={specOptions} />);
// 3) Styles — pre-computed <style> tag with the composition's design-panel tokens.
const styleTags = getSSRStyleTags(specOptions.spec);
// Metadata — injects <title>, <meta name=description>, og:*, canonical.
const metadata = getCompositionMetadata(specOptions, { baseUrl: `${req.protocol}://${req.get("host")}` });
const headHtml = renderMetadataToHtml(metadata);
res.status(200).type("html").send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
${headHtml}
${styleTags}
</head>
<body>
<div id="root">${bodyHtml}</div>
<script type="module" src="/client.js"></script>
</body>
</html>`);
} catch (err) {
console.error("SSR error:", err);
res.status(500).send("Server error");
}
});
app.listen(3000, () => console.log("http://localhost:3000"));
Not-found handling is not shown here. This recipe is the CI-tested happy path. fetchCompositionData throws when no composition matches, so as written an unclaimed URL returns a 500, not a 404. Wrap it before shipping. See Rule 5 and the canonical resolveComposition helper.
Registry file (every registration module imported once at startup):
// registry.ts import "@contentstack/studio-react"; // Rule 2 — root import; triggers built-in registration import "./components/Hero"; // side-effectful registerComponent(...) calls import "./components/CardBand"; import "./components/Footer";
Client entry (hydrates the same tree):
// client.tsx
import { hydrateRoot } from "react-dom/client";
import { StudioComponent } from "@contentstack/studio-react";
import "./registry"; // same registry the server used
// specOptions must match the server's — serialize it into the initial HTML
// via a <script> tag and read it here. Do NOT re-fetch on the client (Rule 3).
const specOptions = (window as any).__STUDIO_SPEC__;
hydrateRoot(document.getElementById("root")!, <StudioComponent specOptions={specOptions} />);
Passing Specoptions to the Client
Serialize specOptions into the initial HTML so the client hydrates with the same data:
res.send(`<!DOCTYPE html>
<html>
<head>${headHtml}${styleTags}</head>
<body>
<div id="root">${bodyHtml}</div>
<script>window.__STUDIO_SPEC__ = ${JSON.stringify(specOptions).replace(/</g, "\\u003c")};</script>
<script type="module" src="/client.js"></script>
</body>
</html>`);
The .replace(/</g, "\\u003c") guards against XSS via </script> sequences inside the JSON.
Fastify
Same three calls, different handler shape:
import Fastify from "fastify";
import { StudioComponent, getSSRStyleTags, getCompositionMetadata, renderMetadataToHtml } from "@contentstack/studio-react";
import "./registry";
import { renderToString } from "react-dom/server";
const app = Fastify();
app.get("/*", async (req, reply) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const specOptions = await sdk.fetchCompositionData(
{ url: url.pathname, searchQuery: url.searchParams },
{ locale: "en-us" },
);
if (!specOptions?.spec) return reply.code(404).send("Not found");
const bodyHtml = renderToString(<StudioComponent specOptions={specOptions} />);
const styleTags = getSSRStyleTags(specOptions.spec);
const metadata = getCompositionMetadata(specOptions, { baseUrl: `http://${req.headers.host}` });
return reply.type("text/html").send(`<!DOCTYPE html><html><head>${renderMetadataToHtml(metadata)}${styleTags}</head><body><div id="root">${bodyHtml}</div></body></html>`);
});
app.listen({ port: 3000 });
Live Preview
Live Preview comes from @contentstack/live-preview-utils, not from @contentstack/studio-react. Install and configure it per install-live-preview. ContentstackLivePreview.init({...}) runs once at app boot.
Then author a client-side component that reloads the page on entry change (plain Node has no in-app router, so window.location.reload() is the reactivity path):
// client-live-preview.tsx (customer-authored)
import { useEffect } from "react";
import ContentstackLivePreview from "@contentstack/live-preview-utils";
export function LivePreviewBridge() {
useEffect(() => {
let first = true;
const unsub = ContentstackLivePreview.onEntryChange(() => {
if (first) { first = false; return; } // skip register-time fire
window.location.reload();
});
return () => { if (unsub != null) ContentstackLivePreview.unsubscribeOnEntryChange?.(unsub); };
}, []);
return null;
}
Mount it inside your client entry alongside <StudioComponent>:
// client.tsx
import { hydrateRoot } from "react-dom/client";
import { StudioComponent } from "@contentstack/studio-react";
import { LivePreviewBridge } from "./client-live-preview";
import "./registry";
const specOptions = (window as any).__STUDIO_SPEC__;
hydrateRoot(
document.getElementById("root")!,
<>
<LivePreviewBridge />
<StudioComponent specOptions={specOptions} />
</>,
);
The name LivePreviewBridge is customer convention. The SDK does not export it. Frameworks with a router (Next.js, Remix) swap window.location.reload() for router.refresh() / revalidator.revalidate(). Loop-safe pattern rules: install-live-preview § 7.
Verify
Run the verification curl test. All three assertions must pass:
- curl http://localhost:3000/some-known-path returns composition body text.
- Response HTML contains <style data-studio-ssr> with --token- custom properties.
- Response HTML contains <title> and <meta property="og:..."> tags.
Runnable reference: examples/node-ssr/baseline-check.ts in the SDK repo, the exact assertions CI runs.
Common Failures on This Recipe
Every entry below maps to a full section in Troubleshooting. This is the shortlist for Node specifically:
- Attempted to call X() from the server: SDK older than the dist-ESM fix (issue #874). Upgrade @contentstack/studio-react.
- Internal components missing: you imported a subpath (@contentstack/studio-react/rsc) into the server bundle without a root import elsewhere. Add import "@contentstack/studio-react"; at the top of registry.ts.
- Empty <main> in the HTML: the composition matched a URL pattern but produced no children. Usually the CMS entry is empty, not an SSR bug. Check the composition in Studio.
- Invalid hook call: two React copies. npm ls react should show exactly one.
- TypeError: Cannot read properties of null (reading 'hasOwnProperty') inside @contentstack/json-rte-serializer: the serializer’s htmlToJson expects an HTMLElement, not a string. In Node you must parse the HTML through jsdom first and pass the resulting body. Full recipe in Troubleshooting: json-rte-serializer in Node.
See Also
- Framework recipes: index: the three-call contract + five universal rules.
- Troubleshooting: every failure mode by symptom.
- Verification: the curl test.
- configure-csr-vs-ssr: the skill that walks through picking a render strategy.