Skip to main content

configure-csr-vs-ssr

When to use​

Whole BYOC plan ("BYOC", "use my components in Studio")? Read byoc-end-to-end instead.

Pick and wire a Studio page's render path: CSR (client, useCompositionData) or SSR/RSC (server, sdk.fetchCompositionData), on Next.js (App/Pages), Remix, Astro or Vite/SPA. Phrases: "CSR or SSR", "Next App Router setup", "hydration mismatch", "SEO meta missing", "Live Preview broken after switching render mode". Also diagnoses broken Studio iframe overrides after a render-mode change.

Mandatory auth preflight: settle the credential before the first API call. Resolve it OAuth-first per authenticate-cma: CS_OAUTH_ACCESS_TOKEN, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. Never ask the user for a session authtoken. If nothing resolves, or a refresh fails with 400 invalid_refresh_token, hand them ! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth (it needs a TTY and a browser, so it cannot be run for them) and wait. 403 error_code 316 is a valid credential aimed at another org: fix the org or the api_key, do not re-authenticate.

Configure CSR vs SSR for a Studio composition route

Read this first: SSR is available on every host​

Every server host renders the composition into the server HTML. Follow your host's page:

Same contract everywhere: fetchCompositionData, <StudioComponent />, getSSRStyles (getSSRStyleTags outside React), and getCompositionMetadata for SEO.

Context​

A Studio composition can be rendered via two data-fetching paths. Both return the same StudioComponentSpecOptions shape. Only where the fetch runs differs.

  • CSR: useCompositionData({ url }) hook. Runs in the browser. Best for Vite SPAs / routes where SEO is not required.
  • SSR / RSC: sdk.fetchCompositionData({ url, searchQuery }, { locale }). Runs on the server. Required for SEO and for routes where the initial HTML must contain the rendered composition.

<StudioComponent specOptions={...} /> is the single renderer for both visitor pages and pages opened inside Studio's builder iframe. It auto-detects mode and attaches the Visual Editor overlay when needed. <StudioCanvas /> is a separate client-only component for the Studio canvas route and is not a render-strategy decision.

SDK shape. studioSdk is the imported namespace. You must call studioSdk.init({ stackSdk, contentTypeUid }) once at boot and use the returned object. Server fetches go through sdk.fetchCompositionData(...) on that returned object.

import { studioSdk } from "@contentstack/studio-react";
export const sdk = studioSdk.init({ stackSdk, contentTypeUid });
// later, server-side:
const specOptions = await sdk.fetchCompositionData({ url, searchQuery }, { locale });

Reference doc: 20-csr-or-ssr.md.

The render-pipeline rule (read this first)​

What determines true SSR (the composition DOM in the server HTML) is whether <StudioComponent> renders through a renderToString pipeline (Pages Router / Vite custom server / Remix) or, on App Router, through a "use client" wrapper, which Next also renders to HTML on the server. @contentstack/studio-react ships no "use client" directive and its renderer calls hooks, so a Server Component cannot render it directly. The data-fetch function (fetchCompositionData vs useCompositionData) is orthogonal and does not by itself produce SSR.

Net: every mainstream host, App Router included (via a client wrapper), server-renders the composition. The SDK no longer emits data-cs-defer-builtin placeholders: older advice about them is stale.

Decision table​

SituationPathTrue SSR? (composition DOM in server HTML)
Vite / React SPA, no SEOCSR: useCompositionDataNo (client only)
Next.js Pages RouterSSR: sdk.fetchCompositionData and getSSRStyles inside getServerSidePropsYes: renderToString
Next.js App Routersdk.fetchCompositionData and getSSRStyles in a Server Component, then a client wrapper renders <StudioComponent />Yes: Next server-renders the client component's initial output
Vite custom-server SSRrenderToString + hydrateRoot + sdk.fetchCompositionData (mirror SDK's test-resources/ssr-react)Yes
Remixsdk.fetchCompositionData inside the loaderYes: renderToString
Static / rarely changingSSG/ISR: getStaticProps + revalidateYes (at build/revalidate. Via renderToString)

CSR works on any framework including App Router. The isLoading gate keeps built-ins out of the server prerender entirely. Use CSR as the universal fallback when true SSR isn't viable on a given framework.

fetchCompositionData does not return "not found": it throws. Wrap it.​

Verified against the SDK source (studio-client/src/utils/composable-studio-error.ts, composable-studio/sdk.ts). On a miss, sdk.fetchCompositionData throws. It does not resolve with hasSpec: false. Every if (!specOptions.hasSpec) notFound() line is therefore dead code on the path it was written for: the await throws first, the framework's error boundary catches it, and the visitor gets a 500 where a 404 belongs.

What it throws is a ComposableStudioError. Do not branch on a list of names. Call isCompositionNotFound(err), exported for exactly this decision:

import { isCompositionNotFound } from "@contentstack/studio-react";

It answers did we establish that nothing is here? and is the only thing that should decide a 404. A list of names has to be kept in step with the SDK, and the cost of drift is one-way: mistake a setup failure for a missing page and a wrong api key or a delivery outage returns 404 on pages that exist, de-indexing a working site.

Also on the error: err.code (the precise case, for logs and support) and err.errorClass, of which only NOT_FOUND is a 404. err.causes carries { cause, fix } pairs for that failure, most likely first: log it in dev, it is the fastest diagnosis available.

// lib/resolve-composition.ts
import { isCompositionNotFound } from "@contentstack/studio-react";

export async function resolveComposition(sdk, args, opts = {}) {
try {
const specOptions = await sdk.fetchCompositionData(args, opts);
return { specOptions, notFound: false }; // resolved ⇒ renderable
} catch (err) {
if (isCompositionNotFound(err)) return { specOptions: null, notFound: true };
throw err; // real failure — must stay a 500
}
}

Do not blanket-catch. A bare try { … } catch { notFound() } turns every CDA outage, expired delivery token, and network blip into a silent 404. The site looks empty instead of broken, and monitoring sees nothing. Ask the predicate. Rethrow the rest.

content_type_url_pattern moves the failure, it doesn't remove it. With url_metadata.url_source: "content_type_url_pattern", the composition's pattern matches a whole family of URLs (/blog/{{entry.title}} matches any /blog/*), so a bad slug sails past pattern match and dies at entry lookup — a different code, URL_MATCHED_NO_ENTRY rather than NO_COMPOSITION_FOR_URL. Both are NOT_FOUND, so the predicate covers both without you listing either; this is the case that used to catch people out when they hand-rolled the list. See troubleshoot-composition-resolution § Runtime error taxonomy.

useCompositionData doesn't throw: it hands you the same object. The hook catches the rejection and stores it verbatim in error, so isCompositionNotFound(error) classifies identically. An if (error) return <ErrorState/> branch renders an error state for URLs that simply have no composition.

Every fetch snippet below is written against resolveComposition. If you copy a raw sdk.fetchCompositionData call from elsewhere, wrap it.

A server with a preview token can serve drafts to visitors​

The Delivery stack is shared by every request on a server process. fetchCompositionData sets a request's preview hash on it and never clears it when the next request has none (by design), so later visitor requests go to the preview host with the last hash. Measured: one request with ?live_preview=<hash>, then the next plain request went to rest-preview.contentstack.com. Mechanism: troubleshoot-canvas § shared stack instance hazard.

A deployment with no preview token is safe (deploy-studio-site). On a preview deployment, clear the hash when a request has none:

const q = new URLSearchParams(searchQuery);
if (!q.get("hash") && !q.get("live_preview")) stack.livePreviewQuery({ live_preview: "" });

Measured: the plain request then stayed on the CDN. Requests in flight at the same time still share the stack; a busy preview server needs a stack per request.

Task​

  1. Pick the path from the decision table using framework. Run only the matching block below. Skip the others.

  2. CSR (Vite / SPA, also the universal fallback on any framework, including App Router).

    "use client";
    import { useCompositionData, StudioComponent, isCompositionNotFound } from "@contentstack/studio-react";

    export default function Page() {
    // window is browser-only — guard for any framework that prerenders this page (Next does, even with "use client").
    const url = typeof window !== "undefined" ? window.location.pathname : "/";
    const { specOptions, isLoading, error } = useCompositionData({ url });
    if (isLoading) return <Loading />;
    // The hook surfaces the rejection as `error`, so a plain `if (error)` branch
    // renders an error state for a URL that simply has no composition. Classify
    // first — a miss is a 404, everything else is a real error.
    const isMiss = isCompositionNotFound(error);
    if (error && !isMiss) return <ErrorState message={error instanceof Error ? error.message : String(error)} />;
    if (isMiss || !specOptions?.spec) return <NotFound />;
    return <StudioComponent specOptions={specOptions} />;
    }

    The hook reads window.location.search automatically, no searchQuery argument required. CSR is the safe universal path: built-ins never enter a server pre-render, so the App-Router RSC crash from registering basics on the server can't happen.

    Ensure built-ins + custom components are registered before render (see register-component). On CSR, the registration must run client-side at module init.

    Live Preview wiring is separate. If this route needs to reflect author edits in real time, add the onEntryChange bridge from install-live-preview (step 7). CSR pages call the hook's refetchData inside the loop-safe callback — it re-fetches just the bound data, which is all a live edit changes.

  3. App Router: SSR through a client wrapper. A Server Component fetches and computes the styles, a "use client" wrapper renders, and Next puts that output in the initial HTML (verified with curl in the App Router recipe). Initialize the sdk in a server-only module (no "use client"). Register components in a "use client" module the wrapper imports. Rendering <StudioComponent /> directly in a Server Component fails with a Next.js client-component error: the renderer calls hooks.

    // app/[[...slug]]/page.tsx — NO "use client"
    import { getSSRStyles } from "@contentstack/studio-react";
    import { sdk } from "@/lib/studio.server";
    import { ClientRender } from "./ClientRender";
    import { notFound } from "next/navigation";

    export default async function Page({ params, searchParams }) {
    const { slug } = await params; // a Promise on Next 15+
    const url = "/" + (slug?.join("/") ?? "");
    const searchQuery = new URLSearchParams((await searchParams) as Record<string,string>).toString();
    const { specOptions, notFound: miss } = await resolveComposition(
    sdk,
    { url, searchQuery },
    { locale: "en-us" },
    );
    // resolveComposition rethrows real failures, so reaching here with miss=true
    // genuinely means "no composition at this URL" — 404 is the right answer.
    // Do NOT add `|| !specOptions.hasTemplate` here. hasTemplate is false for
    // every FREEFORM composition (no connected content type), so that guard
    // 404s every freeform page however it is published. `miss` already carries
    // the only not-found answer there is.
    if (miss) notFound();
    // getSSRStyles here, never in ClientRender: hydration mismatch on the <style>.
    return (
    <>
    <style dangerouslySetInnerHTML={{ __html: getSSRStyles(specOptions.spec) }} />
    <ClientRender specOptions={specOptions} />
    </>
    );
    }

    export async function generateMetadata({ params, searchParams }) {
    const { slug } = await params;
    const url = "/" + (slug?.join("/") ?? "");
    // Metadata must never throw — a miss here should degrade to empty tags,
    // not take down the page that resolves fine one line later.
    const { specOptions } = await resolveComposition(sdk, {
    url,
    searchQuery: new URLSearchParams(await searchParams).toString(),
    });
    const seo = specOptions?.seo;
    return seo
    ? { title: seo.pageTitle, description: seo.pageDescription,
    openGraph: { images: seo.openGraphImage ? [seo.openGraphImage] : [] } }
    : {};
    }

    Client wrapper (renders only; no styles or Live Preview here):

    "use client";
    import { StudioComponent } from "@contentstack/studio-react";

    export function ClientRender({ specOptions }) {
    return <StudioComponent specOptions={specOptions} />;
    }

    Live Preview wiring is separate. If this route needs real-time author updates, mount the LivePreviewBridge from install-live-preview (step 7) once in the app shell (e.g. in app/layout.tsx inside a client boundary). App Router routes call router.refresh() in the loop-safe callback. Do NOT put [router] in the effect deps. It produces an infinite refetch loop. Canvas route stays client-only:

    // app/studio-canvas/page.tsx
    "use client";
    import { StudioCanvas } from "@contentstack/studio-react";
    export default function Canvas() { return <StudioCanvas />; }
  4. SSR: Next.js Pages Router. Also full SSR: the composition DOM lands in the server HTML via renderToString. Register all components (built-ins + custom) at module scope in pages/_app.tsx with static imports so the same React instance runs server + client.

    // pages/[[...slug]].tsx
    import { getSSRStyles } from "@contentstack/studio-react";

    export async function getServerSideProps({ resolvedUrl }) {
    // NOT req.url: on client-side navigation it is /_next/data/<build>/....json.
    const { pathname, search } = new URL(resolvedUrl, "http://localhost");
    const { specOptions, notFound } = await resolveComposition(sdk, {
    url: pathname,
    searchQuery: search.replace(/^\?/, ""),
    });
    if (notFound) return { notFound: true };
    return {
    props: {
    // holds `undefined` values, which next dev refuses to serialize
    specOptions: JSON.parse(JSON.stringify(specOptions)),
    css: getSSRStyles(specOptions.spec),
    },
    };
    }

    Page renders <style dangerouslySetInnerHTML={{ __html: css }} /> then <StudioComponent specOptions={specOptions} />. The JSON round trip drops undefined values (e.g. an unset image), matching what production sends anyway.

    Live Preview wiring is separate. If this route needs real-time author updates, mount the LivePreviewBridge from install-live-preview (step 7) in pages/_app.tsx inside a client boundary. Pages Router routes call router.replace(router.asPath, undefined, { scroll: false }) in the loop-safe callback. Do NOT put [router] in the effect deps. The router identity changes on every replace, which would produce an infinite refetch loop.

    Verify true SSR with next dev or a prod build (next build && next start): both render the same server HTML (checked across five requests and three cold next dev boots). Acceptance: curl <url> returns 200 with the composition DOM (incl. Repeater iteration items) in <body> after stripping <script> tags.

  5. SSR (Remix). Put sdk init in app/lib/studio.server.ts so secrets tree-shake out of the browser bundle.

    import { json } from "@remix-run/node";
    import { sdk } from "~/lib/studio.server";

    export async function loader({ request }) {
    const url = new URL(request.url);
    const { specOptions, notFound } = await resolveComposition(sdk, {
    url: url.pathname,
    searchQuery: url.search.replace(/^\?/, ""),
    });
    if (notFound) throw new Response("Not Found", { status: 404 });
    return json({ specOptions });
    }

    Component: const { specOptions } = useLoaderData(); return <StudioComponent specOptions={specOptions} />;

  6. SSG / ISR. Pass searchQuery: "" (no Studio iframe at build time), set revalidate: 60 for ISR, and wire a Contentstack publish webhook to res.revalidate(path) for instant invalidation. Live Preview won't update SSG output until revalidation. If that matters, move the route to SSR.

  7. Hydration discipline. Pass the same specOptions the server fetched straight to the client wrapper. Do not re-fetch on mount with useCompositionData after an SSR fetch. That produces a hydration mismatch. Pin locale and variantAlias to identical values on both sides.

  8. Iframe override forwarding. On every SSR/RSC path, forward the full request query string into searchQuery. Studio's builder iframe appends its own params (locale, variant, preview entry) to the iframe URL. The SDK reads them itself. Your code must not parse or rely on specific param names. They're an internal contract between Studio and the SDK that may change. Just pass the raw query string through and the SDK does the rest.

  9. Repeater iteration bindings. If the composition includes a Repeater, the same <StudioComponent /> resolves repeater.<fieldUid> bindings against each iteration's data, no extra wiring at the page level.

  10. Verification. Open the route in a regular tab and view-source:. For SSR/RSC/SSG the composition HTML must be in the initial response (not just a shell). curl -A "Twitterbot" <url> returns the og: meta tags. Open the same route inside Studio's preview iframe. The iframe's override params flow through searchQuery and the right entry/locale renders. Edit an entry in Live Preview. The page updates without a full reload. No React hydration warning in the console.

    Verify SSR with curl, not a browser screenshot. Next injects a FOUC guard (body{display:none} via data-next-hide-fouc) during SSR and removes it on hydration. A pre-hydration screenshot shows blank even when the real content is in the HTML. curl -sk <url> | grep -q '<known-text>' executes no JS and returns the truth. A blank browser screenshot does NOT mean SSR failed.

Inputs needed from the user​

  1. framework: one of next-app, next-pages, remix, vite-spa, astro, ssg
  2. routePath: the route file you'll write
  3. contentTypeUid: Studio composition content type uid (defaults to compositions)
  4. defaultLocale: defaults to en-us

If framework is unclear, inspect package.json: next plus an app/ dir means next-app, next plus a pages/ dir means next-pages, @remix-run/* means remix, vite means vite-spa, and astro means astro.

Studio-authored styles on a server-rendered route: getSSRStyles​

Anything an author sets in the Design panel is generated at render time, not shipped in your stylesheet. A server render that emits only markup therefore arrives unstyled and restyles on hydration: a visible flash, worst on the slowest connections.

getSSRStyles(spec) returns that CSS as a string. Emit it with the markup.

import { getSSRStyles, StudioComponent } from "@contentstack/studio-react";

const css = getSSRStyles(specOptions.spec); // design-token :root vars + component styles

return (
<>
{css ? <style precedence="default">{css}</style> : null}
<StudioComponent specOptions={specOptions} />
</>
);

Compute it on the server only: the Server Component (App Router) or getServerSideProps (Pages Router). In a "use client" component the browser computes different CSS, a hydration mismatch.

It takes one spec or an array of them, so a page assembled from several compositions emits one stylesheet. Outside React (Express, a custom server) getSSRStyleTags(spec) returns the same CSS already wrapped in a <style> tag, to concatenate into an HTML template after renderToString.

CSR routes need neither: there is no server pass to be unstyled.

Acceptance​

Succeeds only when ALL of the following are true:

  • The route file uses the fetch path that matches the framework row in the decision table
  • Server-rendered routes emit getSSRStyles(specOptions.spec) alongside the markup, checked by loading the route with JavaScript disabled, or curl, and confirming Design-panel styling is already applied
  • Server-side paths forward searchQuery from the incoming request to fetchCompositionData
  • Every fetchCompositionData call is wrapped, via resolveComposition or an equivalent try/catch that classifies known not-found ids as 404 and rethrows everything else. No bare await sdk.fetchCompositionData(...) on a route, and no blanket catch { notFound() }
  • Verified with a real miss: request a URL with no composition (e.g. /no-such-page) and a valid-prefix-but-bad-slug URL under a connected template (e.g. /blog/zzz-not-an-entry). Both return 404, not 500. The second one is the content_type_url_pattern case and fails at a different stage. Testing only the first proves nothing about it
  • <StudioComponent /> is rendered inside a "use client" boundary on App Router / RSC paths
  • If Live Preview is in scope for this route, the LivePreviewBridge from install-live-preview (step 7) is mounted in the app shell. Not needed for pure public SSR routes without Live Preview.
  • No useCompositionData call exists on a route that already fetched server-side (no double-fetch)
  • App Router routes export generateMetadata populated from specOptions.seo
  • Management tokens, if any, only appear in *.server.ts (Remix) or non-"use client" modules (Next.js)
  • Canvas route, if present, is a separate "use client" route mounting <StudioCanvas />
  • Browser console shows no hydration mismatch warning on the rendered route

If acceptance fails, report exactly which step broke and stop.

Common pitfalls​

PitfallSymptomFix
useCompositionData in a Server Component"Hooks can only be called inside a function component"Switch to sdk.fetchCompositionData + client wrapper
<StudioComponent /> directly in a Server ComponentNext.js client-component errorWrap in a "use client" child taking specOptions as prop
searchQuery not forwardedIframe shows wrong locale/variant. Edit mode doesn't engageForward request.url.search / new URLSearchParams(searchParams).toString()
Re-fetched on the client after SSRHydration mismatch warningPass server specOptions straight to the client wrapper
Different locale server vs clientMismatch warning, content flashPin both sides via the second arg of fetchCompositionData
Live Preview never updates the running pageMissing onEntryChange bridgeWire the LivePreviewBridge from install-live-preview (step 7). Not a CSR-vs-SSR issue, same fix regardless of framework.
Infinite _next/data/<id>/<route>.json (Pages) or RSC payload refetch: Network tab fires hundreds of times per editonEntryChange wired in a [router]-dep effectMove the wiring to the loop-safe pattern in install-live-preview (step 7): subscribe ONCE with empty deps, hold router in a useRef, skip the register-time fire, unsubscribe in cleanup.
transpilePackages: ['@contentstack/studio-*'] on App Router500 with Cannot read properties of null (reading 'useContext') at useData → RepeaterPreview during the RSC server prerenderDon't transpile the SDK on App Router. It defeats the SDK's safe client-deferral (the basics resolve to real components, run on the server, and read a null React dispatcher). On Pages Router, transpiling is fine. (P31)
Coexisting app/ dir while SSR-ing a pages/ routeThe Pages-Router SDK React resolves to the app-page runtime, which gives a null dispatcher and a crashMake the project Pages-Router-only for the Studio SSR route (remove app/ from that workspace). (P31)
dynamic(() => import('@contentstack/studio-react')) for the canvas, prod build failsPackage path "." is not exported from packagestudio-react's exports map only exposes . under the import condition. Dynamic-import a local wrapper that statically imports StudioCanvas. (P31)
Bare await sdk.fetchCompositionData(...) with an if (!hasSpec) notFound() guard below itEvery unmatched URL returns 500, not 404. The fetch rejects, so the guard line never executes. Error boundary shows a generic failure. Nothing names the missing compositionWrap with resolveComposition. The guard was written for a resolve-with-falsy contract the SDK doesn't have
Blanket try { … } catch { notFound() } around the fetchEvery CDA outage, expired delivery token, and network blip silently becomes a 404. The site reads as "empty" instead of "broken" and monitoring stays greenAsk isCompositionNotFound(err): true is a 404. Rethrow the rest so real failures stay 500s
Testing 404 handling only with a totally unmatched URLPasses, while every bad slug under a content_type_url_pattern template still 500s if you hand-rolled a list of names. That path matches the pattern and fails later, at entry lookup, with a different codeTest both shapes. Use the predicate rather than a list, and both are covered
Server-rendered route paints unstyled, then snaps into place on hydrationDesign-panel styles are generated at render time, not in your stylesheet. Markup alone carries none of themEmit getSSRStyles(specOptions.spec) with the markup. See § Studio-authored styles on a server-rendered route
getSSRStyles in the App Router "use client" wrapperHydration mismatch on the <style>Call it in the Server Component page.tsx
Pages Router reads context.req.url404 on client-side navigation: req.url is /_next/data/<build>/....jsonUse context.resolvedUrl
Pages Router returns raw specOptionsnext dev: "Error serializing ... undefined cannot be serialized as JSON"Return JSON.parse(JSON.stringify(specOptions))
Every bound image 400s through next/image ("hostname … is not configured under images")Assets come from a Contentstack CDN host the optimizer doesn't trust by default. Nothing in the composition is wrongAdd the asset hosts to images.remotePatterns in next.config.mjs: images.contentstack.io plus your region's *-images.contentstack.com. Region-dependent, so read the URL the binding actually returned rather than guessing
SSG never updates after publishStale contentConfigure ISR revalidate or wire a publish webhook to res.revalidate(path)
Management token in client moduleToken shipped to browserMove sdk init into *.server.ts / non-"use client" module

See also​

  • install-studio: installs the three SDKs and wires studioSdk.init
  • setup-section-preview: adds the <StudioCanvas /> route
  • troubleshoot-canvas: when the iframe still doesn't render after both skills are run
  • docs/30-look-it-up/05-reference/05-sdk-api.md: every prop + fetcher option the CSR / SSR patterns above pull from

Report a problem with this page