Studio Docs

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