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 sessionauthtoken. If nothing resolves, or a refresh fails with400 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 316is a valid credential aimed at another org: fix the org or theapi_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:
- Next.js App Router: a Server Component fetches and computes styles; a
"use client"wrapper renders. - Next.js Pages Router:
getServerSidePropsfetches and computes styles. - Render on the server: any other server framework (Express, Fastify, Remix, Astro, Gatsby).
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
| Situation | Path | True SSR? (composition DOM in server HTML) |
|---|---|---|
| Vite / React SPA, no SEO | CSR: useCompositionData | No (client only) |
| Next.js Pages Router | SSR: sdk.fetchCompositionData and getSSRStyles inside getServerSideProps | Yes: renderToString |
| Next.js App Router | sdk.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 SSR | renderToString + hydrateRoot + sdk.fetchCompositionData (mirror SDK's test-resources/ssr-react) | Yes |
| Remix | sdk.fetchCompositionData inside the loader | Yes: renderToString |
| Static / rarely changing | SSG/ISR: getStaticProps + revalidate | Yes (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
-
Pick the path from the decision table using
framework. Run only the matching block below. Skip the others. -
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.searchautomatically, nosearchQueryargument 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
onEntryChangebridge frominstall-live-preview(step 7). CSR pages call the hook'srefetchDatainside the loop-safe callback — it re-fetches just the bound data, which is all a live edit changes. -
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 withcurlin 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
LivePreviewBridgefrominstall-live-preview(step 7) once in the app shell (e.g. inapp/layout.tsxinside a client boundary). App Router routes callrouter.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 />; } -
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 inpages/_app.tsxwith static imports so the same React instance runs server + client.// pages/[[...slug]].tsximport { 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 serializespecOptions: JSON.parse(JSON.stringify(specOptions)),css: getSSRStyles(specOptions.spec),},};}Page renders
<style dangerouslySetInnerHTML={{ __html: css }} />then<StudioComponent specOptions={specOptions} />. The JSON round trip dropsundefinedvalues (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
LivePreviewBridgefrominstall-live-preview(step 7) inpages/_app.tsxinside a client boundary. Pages Router routes callrouter.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 devor a prod build (next build && next start): both render the same server HTML (checked across five requests and three coldnext devboots). Acceptance:curl <url>returns 200 with the composition DOM (incl. Repeater iteration items) in<body>after stripping<script>tags. -
SSR (Remix). Put sdk init in
app/lib/studio.server.tsso 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} />; -
SSG / ISR. Pass
searchQuery: ""(no Studio iframe at build time), setrevalidate: 60for ISR, and wire a Contentstack publish webhook tores.revalidate(path)for instant invalidation. Live Preview won't update SSG output until revalidation. If that matters, move the route to SSR. -
Hydration discipline. Pass the same
specOptionsthe server fetched straight to the client wrapper. Do not re-fetch on mount withuseCompositionDataafter an SSR fetch. That produces a hydration mismatch. PinlocaleandvariantAliasto identical values on both sides. -
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. -
Repeater iteration bindings. If the composition includes a Repeater, the same
<StudioComponent />resolvesrepeater.<fieldUid>bindings against each iteration's data, no extra wiring at the page level. -
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 throughsearchQueryand 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}viadata-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
framework: one ofnext-app,next-pages,remix,vite-spa,astro,ssgroutePath: the route file you'll writecontentTypeUid: Studio composition content type uid (defaults tocompositions)defaultLocale: defaults toen-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, orcurl, and confirming Design-panel styling is already applied - Server-side paths forward
searchQueryfrom the incoming request tofetchCompositionData - Every
fetchCompositionDatacall is wrapped, viaresolveCompositionor an equivalent try/catch that classifies known not-found ids as 404 and rethrows everything else. No bareawait sdk.fetchCompositionData(...)on a route, and no blanketcatch { 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 thecontent_type_url_patterncase 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
LivePreviewBridgefrominstall-live-preview(step 7) is mounted in the app shell. Not needed for pure public SSR routes without Live Preview. - No
useCompositionDatacall exists on a route that already fetched server-side (no double-fetch) - App Router routes export
generateMetadatapopulated fromspecOptions.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
| Pitfall | Symptom | Fix |
|---|---|---|
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 Component | Next.js client-component error | Wrap in a "use client" child taking specOptions as prop |
searchQuery not forwarded | Iframe shows wrong locale/variant. Edit mode doesn't engage | Forward request.url.search / new URLSearchParams(searchParams).toString() |
| Re-fetched on the client after SSR | Hydration mismatch warning | Pass server specOptions straight to the client wrapper |
Different locale server vs client | Mismatch warning, content flash | Pin both sides via the second arg of fetchCompositionData |
| Live Preview never updates the running page | Missing onEntryChange bridge | Wire 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 edit | onEntryChange wired in a [router]-dep effect | Move 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 Router | 500 with Cannot read properties of null (reading 'useContext') at useData → RepeaterPreview during the RSC server prerender | Don'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/ route | The Pages-Router SDK React resolves to the app-page runtime, which gives a null dispatcher and a crash | Make 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 fails | Package path "." is not exported from package | studio-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 it | Every 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 composition | Wrap with resolveComposition. The guard was written for a resolve-with-falsy contract the SDK doesn't have |
Blanket try { … } catch { notFound() } around the fetch | Every CDA outage, expired delivery token, and network blip silently becomes a 404. The site reads as "empty" instead of "broken" and monitoring stays green | Ask isCompositionNotFound(err): true is a 404. Rethrow the rest so real failures stay 500s |
| Testing 404 handling only with a totally unmatched URL | Passes, 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 code | Test both shapes. Use the predicate rather than a list, and both are covered |
| Server-rendered route paints unstyled, then snaps into place on hydration | Design-panel styles are generated at render time, not in your stylesheet. Markup alone carries none of them | Emit getSSRStyles(specOptions.spec) with the markup. See § Studio-authored styles on a server-rendered route |
getSSRStyles in the App Router "use client" wrapper | Hydration mismatch on the <style> | Call it in the Server Component page.tsx |
Pages Router reads context.req.url | 404 on client-side navigation: req.url is /_next/data/<build>/....json | Use context.resolvedUrl |
Pages Router returns raw specOptions | next 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 wrong | Add 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 publish | Stale content | Configure ISR revalidate or wire a publish webhook to res.revalidate(path) |
| Management token in client module | Token shipped to browser | Move sdk init into *.server.ts / non-"use client" module |
See also
install-studio: installs the three SDKs and wiresstudioSdk.initsetup-section-preview: adds the<StudioCanvas />routetroubleshoot-canvas: when the iframe still doesn't render after both skills are rundocs/30-look-it-up/05-reference/05-sdk-api.md: every prop + fetcher option the CSR / SSR patterns above pull from