When to Use
Symptom-mapped diagnostic for a non-working Studio canvas. Given a user-visible failure, narrow it to the underlying cause and propose the fix. Run a Section-test pre-flight first.
Use when Studio’s canvas misbehaves with a specific symptom: “canvas is blank”, “Component Loading Error”, “canvas shows wrong composition”, “preview entry doesn’t update”, “iframe never finishes loading”. If the symptom is ambiguous, route via troubleshoot first. Do NOT use to validate a fresh install (use verify-setup). Do NOT use for authoring-quality issues: those are skill-correctness.
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. 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.
Troubleshoot Studio Canvas Issues
Context
Studio’s canvas iframe is downstream of four moving parts: Delivery SDK auth, Live Preview’s post-message channel, the canvas-app’s React rendering, and the saved composition spec. A symptom can come from any of them. This skill maps each symptom to its root cause.
Pair with verify-setup if the user is freshly set up: verify-setup walks all four layers, troubleshoot-canvas assumes you already know the symptom.
Reference: docs/setup/troubleshoot-common-studio-issues.md (the canonical table of symptoms and their fixes).
Step 0: is the App Actually Being Served?
Do this before the section-test, before anything. Studio iframes your app. If nothing answers on that origin, every downstream symptom appears: blank canvas, “SDK Not Initialized”, “Template Did Not Load”, “localhost didn’t send any data”. None of them says “your dev server isn’t running”, and it is a routine first cause: the server was never started, it crashed, it took a different port on restart, or you’re serving vite preview on :4173 while Studio points at :5173.
# 1. Is anything listening on the port Studio targets?
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:<port>/canvas
# 2. What origin does Studio actually iframe? (must match, scheme included)
# Sections -> env base URL + Canvas URL
# Templates -> env base URL + the template path
# GET /v3/environments/<env> -> urls[].url
A connection error or a non-2xx/3xx on step 1 means stop. Start the server (or fix the port/scheme mismatch) and re-test. Only when the URL Studio targets returns content does the rest of this skill apply.
Cheap, and it costs nothing when it passes. Also re-run it after any crash, port change, or switch between npm run dev and vite preview.
Run This SECOND: the Section-Test Pre-Flight
Before reading the symptom table (especially for ”SDK Not Initialized”, blank canvas, or any popup that names the SDK as broken), run this one diagnostic. It cuts the longest debugging path from hours to seconds.
The section-test: open any Section (not a Template) in Studio. Does it render with real data?
- If YES, the SDK, the credentials, the editing handshake, the Live Preview channel, and the Delivery SDK auth are ALL working. Whatever symptom you’re seeing on a Template is downstream of the SDK. Skip the SDK / init / token rows in the table. Jump straight to troubleshoot-composition-resolution. The most common cause is the connected template’s URL match (literal URL, missing leading slash, or API-set user_specified_pattern that reverted to default, see #9 in the missing-skills audit).
- If NO (sections also fail), THEN the SDK / init / token rows below are honest. Continue to the symptom table.
Why this matters: Studio’s “SDK Not Initialized” popup is a red herring in the most common case. When a Template’s useCompositionData never resolves (URL match fails), <StudioComponent /> never mounts, Studio’s editing handshake times out, and Studio surfaces that as “SDK Not Initialized”, blaming a layer that isn’t broken. Re-running install-studio won’t help. The install is fine. The section-test rules SDK-broken in or out before you spend hours in the wrong layer.
Diagnostic tooling: inspecting a stored composition’s ui
When a row below points at the stored composition (node tree, bindings, data sources, URL metadata), fetch via the Studio API. The ui field is zlib:<base64>: a literal zlib: prefix + base64-encoded zlib-deflated JSON. Decode for read-only inspection:
// Node 18+. Save as inspect-ui.js
import zlib from "node:zlib";
function decodeUi(ui) {
if (!ui.startsWith("zlib:")) return JSON.parse(ui); // legacy plain-JSON fallback
const bytes = Buffer.from(ui.slice("zlib:".length), "base64");
return JSON.parse(zlib.inflateSync(bytes).toString("utf8"));
}
// Usage: pipe the composition JSON in on stdin, get the decoded ui on stdout
const composition = JSON.parse(await new Response(process.stdin).text());
console.log(JSON.stringify(decodeUi(composition.ui), null, 2));
# Quick CLI: fetch a composition and pipe through the decoder curl -s "<studio-api-host>/v1/projects/<projectId>/compositions/<compositionId>" \ -H "$CS_AUTH" -H "organization_uid: <org>" | node inspect-ui.js # $CS_AUTH is either credential: 'authorization: Bearer <oauth>' (preferred) or # 'authtoken: <session>'. Both work since 2026-08-25 — see authenticate-cma.
Read-only (for debugging). Do not author or modify compositions through the API. You’d lose UI-generated metadata (url_queries, etc.) that the Edit-URL modal sets behind the scenes. If you want to write a composition by hand, step back into Studio’s UI.
Task
Match the symptom to the canonical category using the table below. Pick the closest match: exact wording matters less than category.
Run the “Check first” diagnostic for that category. If it confirms the diagnosis, propose the fix. If not, run the secondary check.
Report the symptom you matched, then the likely cause, then the exact thing to check, then the fix. Do NOT propose more than two checks. If both come back negative, escalate to reading docs/setup/troubleshoot-common-studio-issues.md or running verify-setup.
Symptom and cause matrix
| Symptom (user-visible) | Most likely cause | Check first | Fix |
|---|---|---|---|
| Canvas iframe is completely blank (no Studio chrome inside) | The composed iframe address is wrong. Studio builds it as Base URL (origin) + Canvas URL (path): the origin is the per-locale URL on the environment the project targets, the path is the Canvas URL. Either half can be broken. An empty env Base URL is the most common cause (and surfaces as a “no base URL”-style error blaming the Studio project, not the environment). | (a) In Contentstack, open the stack’s Settings, go to Environments, open <env>, and check the URL for <locale>: is it set to where the canvas-app serves (http://localhost:5173 for local dev, or the deploy origin)? (b) In Studio, open the project’s Settings, then Configuration: is the project’s Environment that same env, and is Canvas URL the route path (/canvas, not a full origin)? | Set the env’s per-locale Base URL to the canvas-app origin. Set Canvas URL to the route path. Ensure the project targets that env. Re-open the composition. See setup-section-preview. |
| Canvas loads but shows the host app’s home page instead of the composition | The host app doesn’t have a /canvas route mounting <StudioCanvas /> | grep -r "StudioCanvas" src/ in the canvas-app | Add a /canvas route that mounts <StudioCanvas />. See docs/setup/studio-project/section-preview-route.md. |
| ”Component Loading Error: Component with type ‘X’ is not registered” where X is repeater / condition-block / section-slot / box / text / another built-in | The SDK auto-registers built-ins on the authoring canvas path. Seeing this on the canvas means the registry instance was bypassed (e.g. importing getComponentRegistryInstance from a different worker/realm than the one rendering the canvas), or a bundler quirk shipped an older SDK build. | Confirm all @contentstack/studio-* packages are on the same major and run in the same JS realm. | Track the SDK issue for system-component auto-registration on the render paths. |
| Visitor page falls back to “No page here yet” / a <NotFound/> branch even though the composition is published and useCompositionData returns a non-thrown error with ComposableStudioError: [Composable Studio SDK] Internal components missing: page (also seen for box, text) | Pre-patch SDK (1.3.1 / 1.5.1 and earlier, or any pre-merge build of fix/builtin-ssr-registration). The visitor render path didn’t auto-register built-in/system components (page, box, text, …). Only the authoring canvas did. Compositions whose root node is type page couldn’t render on the public route. | In DevTools, log the return of useCompositionData (or whatever hook drives the route), then look for state: "error" with Internal components missing: page in the body. Compositions with page (or another system type) as the root node are affected. | Upgrade @contentstack/studio-react to the next 1.3.x / 1.5.x patch. PR #851 (fix/builtin-ssr-registration) restores auto-registration of built-ins on the visitor path. |
| ”Component Loading Error: Component with type ‘X’ is not registered” where X is a section composition UID OR one of the user’s component types | Section ref resolves to a section that isn’t preloaded OR a registered component for type: 'X' doesn’t exist | Open the composition in Studio, then check Layers for a node with type: "X". Is it a section ref (X is another composition’s UID) or a registered component (X is a registered type)? | If section ref: confirm the SDK auto-registers sections, see F-018 fix in docs-verification-findings.md. If registered component: run register-component for that type. |
| ”Component Loading Error / Composition Loading Error” for MANY or ALL of your components at once (e.g. every home-hero / cs-* section errors, not just one), often the whole composition fails to load | NOT a per-component registration gap: the canvas is iframing an app that has NONE of your registerComponent calls, because the project’s Configuration is unset or points at the wrong app. A blank/disabled Base URL, a missing Canvas URL, or an Environment that resolves to a deployed build without your registrations all produce this. The registrations exist in your running app. The canvas just isn’t loading it. | In Studio, open the project’s Settings, then Configuration: is Environment set (to the env whose Base URL points at your running app), is Base URL set to that app’s origin (e.g. http://localhost:3000, http://localhost needs no HTTPS), and is Canvas URL set (/canvas, required)? Any one blank or disabled produces this error. | In Settings, under Configuration, set Environment, ensure the env’s Base URL points at your running app, and set Canvas URL to /canvas. Save, then re-open the composition. This is the single most common “everything’s not registered” cause. See configure-studio. |
| ”Template Did Not Load” + the iframe shows your app’s 404 (the requested URL has a doubled locale, e.g. /en/en) | The locale prefix is carried twice. Studio previews at per-locale Base URL + the composition’s own URL. On a locale-prefixed app the Base URL for en-us is http://localhost:3000/en by design, so the doubling comes from the other side: the composition URL was authored as /en (or /en/...) instead of /, or the catch-all passes the prefixed request path to Studio instead of stripping it. | Read the exact requested URL in the dev-server log / Network tab. A doubled /en/en confirms it. Then check the composition’s url in Studio and the url your catch-all passes to fetchCompositionData. | Composition URLs never carry a locale prefix: fix the authored URL to /. The catch-all strips the app prefix before calling Studio and passes the stack locale as { locale }. Keep the prefix in the per-locale Base URL. On an app with no locale prefix the Base URL is the bare origin. The single rule is in convert-project-to-studio § Step 3. |
| Images render on the published/visitor page but are broken in the canvas / Live Preview (broken URLs look like assets.contentstack.io/spaces/… and return 404) | The image field is backed by a DAM / Assets-Manager asset (am… uid, parent_uid). Live-Preview (draft) resolves it to the DAM preview delivery URL (/spaces/…), which 404s in a plain browser. The published CDA resolves the same asset to images.contentstack.io/v3/assets/… (HTTP 200). | Compare the draft (preview token) vs published (delivery token) URL for the same field. | Link the asset so it delivers the images.contentstack.io form: re-pick it via the DAM/asset picker, or set the asset uid with a session authtoken (OAuth Bearer CMA writes reject DAM assets, see author-composition-via-api pitfalls). Then re-publish + re-open. |
| ”Authentication failed” / 401 / 403 in the canvas | Delivery Token or Preview Token in .env is wrong or has been revoked | In Contentstack, open the stack’s Settings, go to Tokens, open the relevant Delivery Token, and copy the current Delivery Token plus its paired Preview Token into .env | Restart the dev server. |
| ”Component Loading Error: HTTP status 0” / CORS error on the CDA preview host in the canvas iframe (“Access to XMLHttpRequest at https://<cdn>-preview.contentstack.com/… blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header”). Looks like a CORS issue but almost always isn’t. | The Preview Token the canvas-app passes to the Delivery SDK / Live Preview SDK is invalid, missing, “expired” (its paired Delivery Token was deleted/regenerated), literally "undefined", empty, or scoped to a different stack. Contentstack’s preview API returns 401 with a clear body ({"error_message":"You're not allowed in here unless you're logged in.","error_code":105,"errors":{"preview_token":["is not valid."]}}) but without CORS headers (error responses don’t carry them). The browser hides the body and surfaces a CORS error instead. Common forms: token left as the literal string "undefined" by a provisioning script that couldn’t mint it via CMA. The paired Delivery Token was deleted/regenerated (Preview Tokens can’t be revoked individually, they die with their Delivery Token). Copy-paste truncation / trailing whitespace. Token belongs to a different stack than the configured API key. | Bypass the CORS masking with curl. CORS doesn’t apply to curl, so you see the real status code + body. Plug in the values your canvas-app uses (wherever it loads them from: .env.local, .env, runtime config, secrets manager, hardcoded): curl -i "https://<preview-host>/v3/content_types/<CT>/entries?environment=<env>&locale=<locale>" -H "api_key: <stack-api-key>" -H "preview_token: <preview-token>" For the NA prod region the preview host is rest-preview.contentstack.com. Non-prod / non-NA regions use a different host (see install-studio § Regional host map). Outcomes: • HTTP 200 + JSON entries means the token is fine, cause is elsewhere • HTTP 401 + "errors":{"preview_token":["is not valid."]} means the token is bad (revoked, "undefined", empty, wrong stack), most likely • HTTP 403 means the token is valid but lacks scope for this CT / environment • HTTP 422 means an environment / locale name typo • HTTP 400 + "Please provide live preview hash in request" means the token IS valid. The 400 is just because curl didn’t send the live-preview hash header your canvas-app’s SDK adds automatically. Treat this as a pass. | (a) Open the file/config where your canvas-app loads its Preview Token (project-specific: Vite .env.local, Next .env.local, CRA .env, Remix env, runtime fetch, secrets manager, whatever your project uses) and verify the value is NOT the literal string undefined or empty. (b) In Contentstack, open Settings, go to Tokens, then Delivery Tokens, open the Delivery Token your app uses, and confirm a Preview Token (read only) field is visible. If only a Create Preview Token button shows, click it to mint one (see enable-visual-experience Step 4). If the Delivery Token doesn’t exist or looks unfamiliar, the original was deleted. Create a fresh Delivery Token, mint a new Preview Token on it. (c) Copy the current Preview Token (starts with cs) into wherever your canvas-app loads it from. (d) Restart the canvas-app server: env vars and bundle-time config bake in at build/start time. Hot-reload typically does NOT pick up env-file changes for import.meta.env.* / process.env.* reads. (e) Hard-reload the Studio canvas. The CORS-shaped error disappears once the API can authenticate. |
| Preview token looks right in .env but the canvas still 401s: fresh provision, you re-checked the token character-by-character against the Delivery Token page, it matches, the canvas still fails | .env.local is shadowing .env. Vite / CRA / Next load env files in this order: .env first, then .env.local, then .env.[mode], then .env.[mode].local. Later files override earlier ones for the same key and .env.local is loaded in every mode. A stale .env.local left from a previous provisioning run (often carrying <PREFIX>_CONTENTSTACK_PREVIEW_TOKEN=undefined as a literal string) silently wins. Symptom is indistinguishable from a bad token in .env. | ls -la .env.local .env: does .env.local exist? Print its preview-token value: grep PREVIEW_TOKEN .env.local. Is it undefined, empty, or different from .env? | Either (a) delete the stale .env.local, (b) rewrite it from .env.example with the current values, or (c) unset the offending key from .env.local. Restart the dev server: env files bake in at start time. Then re-run the preview-token curl from the row above to confirm 200/400 (pass), not 401. See also install-studio step 7 § .env.local shadowing trap. |
| HTTP 412 “We can’t find that Stack. Please try again.” on CMA/CDA calls + ”No composition for /…” at runtime, most commonly after scaffolding a new project by copying a working app’s directory | Stale .env.local carrying the previous project’s api_key. Same .env.local shadowing trap as the 401 row, but the failing field is the stack api_key (not the preview token). The app initializes the OLD stack while Studio’s project points at the NEW one, and that mismatch produces a 412 from the CDA. | grep -E "API_KEY|api_key" .env.local .env: compare the values. If .env.local carries a stack key from a different project, that’s the cause. Also verify with a CDA probe: curl -i "https://<cdn>/v3/content_types" -H "api_key: <what-your-app-actually-loads>" -H "access_token: <delivery-token>". A 412 confirms wrong stack. | Delete or rewrite the stale .env.local from .env.example. Restart the dev server. Re-probe with the CDA curl: 200 means the stack init now matches Studio’s project. |
| Canvas loads but preview entry is the wrong one | The preview-entry override isn’t reaching the SDK fetch | Studio navbar shows the entry picker. Did the user pick a different entry but the iframe doesn’t update? | Hard-reload the canvas iframe. If still wrong: verify the canvas route mounts <StudioCanvas /> (which reads Studio’s iframe params itself). Do NOT have your own code parse Studio’s internal cs-* URL params: that contract is internal and may change. If you fetch via sdk.fetchCompositionData on the server, ensure the full request searchQuery is forwarded. |
| Canvas iframe never finishes loading (spinner forever) | The canvas-app is starting and failing silently | Open the canvas URL directly (not through Studio). What does the host app show? | If host app errors: fix the host app first. If host app loads fine: clear the iframe’s beforeunload handler (run window.onbeforeunload = null in DevTools console). |
| ”Live Preview not configured” banner in the canvas | App is missing @contentstack/live-preview-utils install OR ContentstackLivePreview.init() was never called | grep -r "live-preview-utils" package.json src/ | Install the package and call init() at app shell. See install-live-preview skill. |
| Bindings render the literal path (e.g. “{{entry.title}}”) | The Delivery SDK call inside the canvas-app didn’t fetch the entry. Bindings can’t resolve | Open Network tab in the canvas-app’s DevTools. Was a CDA call made? | If no CDA call: the host app’s <StudioCanvas /> mount is missing the SDK setup. If CDA call returned 200 but body is empty: preview entry UID is wrong. |
| Compositions list is empty | Stack or project mismatch | In the Studio top bar, which project is selected? Is the stack key in .env the one that owns this project? Compare the connectedStackApiKey from GET /v1/projects against the api_key the app initializes. | Select the right project. configure-studio § Select the Studio project lists them and checks the stack match. Otherwise re-run provision against the right stack. |
| ”Beforeunload” dialog blocks every navigation | Studio’s leave-without-save guard is firing | Save any unsaved edits, OR window.onbeforeunload = null in DevTools to force-clear | Save state first. The guard is intentional. |
| Section drops on the canvas show “Add a condition for X schema” | Repeater is bound to a Reference or Modular Block but no Condition Block wraps the iteration | In Layers, does the Repeater have a Condition Block as a direct child? | Accept the inline prompt to wrap, OR invoke use-condition-block skill. |
| Whole composition renders blank or partially blank (only some sections paint, the rest of the page is empty) AND console shows TypeError: Cannot read properties of undefined (reading 'slots') together with Cannot find a descendant at path [...] in node | An empty Section Slot is fatal page-wide. The SDK renderer walks the composition tree. When it hits a Section Slot whose children array is empty (component removed but slot kept, or slot never filled), it throws, aborting the ENTIRE composition’s render, not just that slot. Every section that would have painted after the throw goes blank. (This is a Studio renderer bug: an empty slot should render nothing, not throw. Report upstream.) | (a) Decode the path in the error ([<page-slot-uid>, <index>, <repeater-slot-uid>, <index>, …]) into the composition tree. (b) Inflate the published ui via the zlib:<base64> decoder snippet in author-composition-via-api § Diagnostic tooling. Walk the same path. (c) The terminal node is the empty Section Slot. Properties panel for that node also confirms with “The slot is empty, with no component added yet.” | Either drop a component back into the slot, OR delete the slot from the section. If authoring via API, ensure every slot: { <uid>: [...] } value is a non-empty array AND no node references a removed descendant. Validate before publishing. The adv-post-message ACK / highlight-node console lines that often accompany this are benign editor-hover noise, not this bug. |
| Live site (or CDA fetch) shows a different tree than the canvas Layers panel: fewer sections, missing recent edits, “this works in Studio but not in production” | The canvas Layers panel is the DRAFT. The published composition (what CDA serves and the live site renders) is whatever was last Saved + Deployed. Unsaved or undeployed canvas edits exist only in the draft session. A broken draft can also mask what’s actually published. | (a) Fetch the published composition via CDA: curl -i "https://<cdn-host>/v3/content_types/<compositions-CT>/entries/<entry-uid>?environment=<env>" -H "api_key: <key>" -H "access_token: <delivery-token>". (b) Inflate the ui field (it’s zlib:<base64>, use the decoder in author-composition-via-api § Diagnostic tooling). (c) Diff the inflated tree against what the canvas Layers panel shows. Match = same composition, mismatch = there are unsaved or undeployed edits. | Save (Cmd/Ctrl+S in canvas) then Deploy (top-right button). Re-fetch via CDA. The inflated tree should now match Layers. Pair with deploy-studio-site for the full Save and Deploy pipeline rationale. |
| Canvas iframe is blank in Chrome v136+ AND DevTools console shows net::ERR_BLOCKED_BY_PRIVATE_NETWORK_ACCESS_CHECKS (or you see no console errors at all but the iframe never paints) | Chrome’s Local Network Access protection blocks localhost:<port> iframes loaded from a non-local origin (e.g. app.contentstack.com loading localhost:6846). Affects all Chrome v136+ installs. | In DevTools, open the Network tab and find the canvas iframe request: does it show as blocked, or with a (failed) status and LocalNetworkAccess in the reason? | Relaunch Chrome with --disable-features=LocalNetworkAccessChecks. macOS: open -a "Google Chrome" --args --disable-features=LocalNetworkAccessChecks. Add the same flag to your Playwright MCP launch one-liner if applicable. |
| Canvas iframe is blank AND you’re using Chrome’s default profile in Studio MCP/automation setup | Chrome v136+ on org-managed devices (MDM/policy-enrolled) refuses to start the --remote-debugging-port listener on the default user profile. Silent failure: lsof -i :9222 is empty even though open -a "Google Chrome" returned 0. | lsof -i :9222: empty? chrome://policy lists DeveloperToolsAvailability or similar enterprise policy? | Use a dedicated --user-data-dir="$HOME/.chrome-mcp" profile (the default in install-playwright-mcp). The policy applies to the default profile only. An empty user-data-dir is treated as a personal profile. |
| ”CT not found” / 404 from Delivery SDK when querying compositions, e.g. stack.ctQuery('compositions') returns 404 | The compositions CT UID the app reads doesn’t match what Studio’s provisioning created. Agent-provisioned projects often use agent_compositions, not compositions. Value is per-project. | In Studio, open the project’s Settings, then General, then Integration code (URL: /studio/projects/<projectId>/settings/general). Copy the CT UID literal from the snippet. Then grep the canvas-app for where the UID actually lives: could be .env*, src/lib/contentstack.ts, a config file, or a hard-coded ctQuery("…") call: grep -rn "compositions|contentTypeUid|COMPOSITIONS_CT" src/ lib/ app/ config/ .env*. | Update every location the UID appears (don’t assume it’s in .env only) to Studio’s integration-code value. Restart the dev server so env-derived reads pick up the change. |
| Studio canvas iframe loads but Studio’s hover/edit overlays don’t appear. studio-renderer.tsx console.log of URL params shows the wrong key | Studio sends the composition UID as cs-composable-uid in its iframe URL, but older studio-renderer.tsx templates parse cs-composition-uid. A mismatched key makes the renderer read undefined, so there are no overlays and no composition mount. Likely a real bug worth upstreaming. | grep -n "cs-composition-uid|cs-composable-uid" src/ in the canvas-app | Patch studio-renderer.tsx to accept BOTH keys: const uid = params.get("cs-composable-uid") ?? params.get("cs-composition-uid");. File an issue against the renderer template repo so the official template gets fixed too. |
| ”Invalid hook call” / minified React #321 when the canvas tries to render a registered component | The SDK peer-deps react ^18.0.0 || ^19.0.0, so React 19 itself is fine. The error means either (a) two React instances in the bundle (a peer override or a nested dep pulls a second copy, or a stale @contentstack/studio-react below 1.8.0 was forced through on React 19), (b) Vite’s dev-optimizer pre-bundled the SDK with a separate React even though node_modules is deduped, (c) the registered component has arity 0 (function Header() { … }): the SDK treats parameterless functions in registerComponent({ component }) as lazy-loader thunks (() => import("...")), wraps them with React.lazy, and ends up calling the function outside React’s render path. | (a) npm ls react must show exactly one entry, 18.x or 19.x. Two entries means a dep or an override pulls a second copy. npm ls @contentstack/studio-react below 1.8.0 on React 19 means a stale pin. (b) Stack trace shows two optimized React bundles with different ?v= hashes. (c) Find the component: value in the registration that has zero parameters. | (a) One React major in ls react, @types/* matching it, and @contentstack/studio-react 1.8.0 or later. Drop any overrides/resolutions that force a second copy. (b) Serve a prod build to the canvas (vite build && vite preview). Rollup dedupes, the dev optimizer can split. (c) Wrap the component with (props) => createElement(Comp, props) or add a props arg to the source. See register-component arity rule. |
| ”SDK Not Initialized” popup | The popup is misleading: this is a red herring in the common case. Studio surfaces “SDK Not Initialized” whenever the canvas-app’s editing handshake times out, regardless of why it timed out. The most common cause is a connected template whose useCompositionData never resolves (URL match failed: literal url, missing leading slash, API-set user_specified_pattern that reverted). <StudioComponent /> never mounts, handshake times out, popup appears blaming the SDK. The SDK itself is usually fine. | Confirm the dev server answers (Step 0), then run the section-test pre-flight. Open any Section (not a Template). If it renders, the SDK is fine. Jump to troubleshoot-composition-resolution. If sections ALSO fail, suspect SDK / init / token (see install rows below). Do NOT re-run install-studio before doing this test. | If sections render: resolve the underlying URL match per troubleshoot-composition-resolution. Typically: ensure the entry’s url field starts with /, the composition url_metadata.url_source is content_type_url_pattern for a page-type CT, and the composition url contains a {{entry.x}} pattern (a literal URL never resolves). If sections also fail: re-check SDK init order, credentials, and that studioSdk.init(...) was actually called before the canvas mounts. |
| Connected-template canvas shows “localhost didn’t send any data” / “SDK Not Initialized” while sections render fine | Connected templates iframe at the environment’s base URL + the template path, NOT at canvasUrl (only sections use that). If the env base URL is HTTP and the dev server is HTTPS-only, the iframe gets nothing back. | GET /v3/environments/<env>: check urls[].url. Compare to the served app’s scheme (HTTPS for local mkcert dev). | PUT /v3/environments/<env> to set urls[].url to the HTTPS URL of the served app (must match the scheme canvasUrl uses). |
| ”Template Did Not Load” in the editor, but the deployed/SSR site renders the template correctly | The template has section-composition nodes in its ui but the template entry’s linked_sections reference field is empty/null (P27). The Studio editor builds spec.sectionCompositions from ?include[]=linked_sections on load: an empty field yields no section specs, so it cannot resolve the nodes and shows “Template Did Not Load”. SSR walks the ui nodes directly via compositionUID and works regardless. API-authored templates are the common culprit: the Studio UI auto-populates linked_sections when an author drags a section onto the canvas. API authoring must do it explicitly. | (a) Confirm SSR works: await sdk.fetchCompositionData({ url }) from a one-off server route returns a usable spec.data.section_scoped_data (or just renders the template). (b) Inspect the template entry’s linked_sections directly: curl -s "<cdn>/v3/content_types/<comp-CT>/entries/<template-entry-uid>?include[]=linked_sections" -H "api_key:…" -H "access_token:…" | jq '.entry.linked_sections | length'. If 0, that’s the cause. | Populate the template entry’s linked_sections with [{ uid: "<section-comp-entry-uid>", _content_type_uid: "<compositions-CT-uid>" }, …]. One entry per distinct placed section, deduped. Publish. See author-composition-via-api § Authoring a Template: populate linked_sections for the recipe. |
| An empty render that was working a minute ago, with no code changes (empty grid in a section that rendered products, blank Compositions list, blank Templates tab, and the thing rendered correctly seconds earlier) | A transient 5xx from the Studio API: /composable-studio-api/v1/projects/... returns 503 / 502 / 504 intermittently. Studio gets no data, renders empty. Not your binding, not your CT, not your config. | In DevTools, open the Network tab, filter to composable-studio-api, and look for a 503 / 502 / 504 around the time the render went empty. If you find one, that’s the cause. If everything in Network shows 200s, the empty render is something else, keep debugging. | Reload. That’s the fix when it’s transient. Do NOT chase a transient 5xx as a binding bug: empty renders look identical to real binding failures and you’ll burn an hour walking through the wrong cause space. If the 5xx reproduces consistently (not just one-off), it’s no longer transient. Escalate with the API path, project ID, and timestamp. |
| Repeater (or any data-driven container) still empty AFTER toggling Preview Mode on | Preview Mode controls whether bindings resolve, not whether they resolve correctly. Once on, flipping it again does nothing: the binding shape is wrong. | Confirm Preview Mode is on (in the right panel, under Configuration). If on AND empty, the binding is wrong. | Diagnose the binding: re-check Bind items source, confirm items exist in payload (DevTools Network), verify scope (see use-repeater § The scope rule, in user terms), confirm a Condition Block wraps Reference / Modular Block iteration. See troubleshoot-data-binding, build-repeating-section. |
| A registered component (or child Section) dropped into a Section Slot renders full-bleed / full-screen, looks correct on the live storefront, broken in Studio | The parent Section is missing a layout container around the Slot. See use-section-slot.md § Layout container for the canonical rule. | Open the parent Section, go to Layers, and confirm the Slot sits inside a sized Box. If it’s a direct child of the Section root, that’s the bug. | Wrap the Slot in a layout container (grid / sized Box). Do NOT add max-width on the component. See use-section-slot.md § Layout container. |
| ”No Studio Code on This Route” (“The front-end code for this URL doesn’t use Studio yet”) for a route that DOES mount <StudioComponent> / <StudioCanvas> | This banner reflects what the iframe’s response looked like, not your source code. A local build/syntax error (a broken import, an unterminated statement, a stray character from mid-edit) makes the dev server return a compile-error overlay instead of your app’s real output. Studio can’t recognize that as Studio-integrated markup and falls back to the generic “custom code” message. | Open the exact URL directly in a plain browser tab (not through Studio’s iframe). Do you see your app, or a Next.js / Vite / Turbopack error overlay? Also check the dev-server terminal for a parse/compile error timestamped around the request. | Fix the actual build error, reload. The banner clears once the route serves real content again — don’t chase the Studio integration, chase the build error. |
| Live Preview intermittently doesn’t apply: the same composition/entry resolves against rest-preview.contentstack.com (drafts visible) on one internal fetch and falls back to cdn.contentstack.io (published-only) on the next, within the same resolution, with no code change in between | Shared, mutable Live Preview state on a stack instance reused elsewhere in the app. See § Live Preview state resets mid-request below. | Log the live_preview request header across consecutive outgoing calls to the same stack (see § below for the exact snippet). Does it go from populated to empty partway through one resolution? | Give Studio’s stackSdk its own independent stack instance instead of reusing one shared with the rest of the app. Full mechanism and fix in § Live Preview state resets mid-request. |
Inputs Needed From the User
- symptom: one sentence. If the symptom doesn’t clearly match a row, ask the user to describe what they see (canvas-iframe content vs surrounding UI) before guessing.
Acceptance
This skill succeeds only when ALL of the following are true.
- The reported symptom was matched to one row in the table above.
- The “Check first” diagnostic was run (or the user was directed to run it).
- If the diagnostic confirmed the cause, the fix was applied or the user was given the exact action to take.
- If the diagnostic failed to confirm, the second-most-likely cause was offered (or escalation to verify-setup / docs/setup/troubleshoot-common-studio-issues.md was suggested).
- The user was NOT given more than two diagnostic guesses in a row. If both miss, escalate to systematic verification.
Third-Party Global-Script Overlays Inside the Canvas: How to Spot + Fix
Widget/banner overlap (OneTrust, chat bubbles) or analytics pollution is NOT this section: that’s gate-third-party-scripts, which applies the SDK’s isStudioCanvas() editor-mode contract. This section is for third-party loaders that crash with error overlays.
Symptom: the Studio canvas renders fine, but a runtime error overlay pops in the iframe: pageerror: Load error! / Failed to load lytics / net::ERR_ABORTED from kit.fontawesome.com / a next/script “Load error!” from a commerce embed. The composition renders correctly BELOW the overlay: the error isn’t from Studio.
Root cause: the app’s canvas route inherits parent layouts, and those layouts inject analytics tags / third-party embeds / global <Script>s. Many of those loaders throw new Error(...) in their onload/onerror handler when their remote asset 403s or their init misconfigures. The throw surfaces as a runtime-error overlay inside Studio’s canvas iframe. Nothing to do with Studio, everything to do with the loader running on the canvas route.
Diagnose in one minute:
- Open the canvas route directly in a normal browser tab: http://localhost:<port><canvasPath>.
- In DevTools, open Network and filter by “Failed” or by hostname (lytics, fontawesome, third-party CDN of your embed).
- In DevTools, open Console: any pageerror line points at a script URL. That script is your culprit.
Fix in order of preference:
- Isolate the canvas route from app chrome (best). Route-group / layout guard so analytics + embeds + global scripts do NOT run when the path matches */canvas. Full recipe in setup-section-preview § Canvas route isolation. This is the correct long-term shape: the canvas route should be minimal by design.
- Patch loaders that throw. Some SDKs let you swap the throw for console.warn, e.g. Lytics jstag‘s tag-load handler can be modified to warn + return instead of throw. Keeps the tag stub on other pages that use it, kills the overlay.
- Remove vestigial loaders. FontAwesome kits often persist in <Script> tags after the app moved to a different icon system. Grep the app for fa- class usages. If there are none, delete the kit script.
- Prefer plain <script async> over next/script. next/script (Next.js) throws its own “Load error!” when a network error occurs. A plain async <script> fails silently to the console. Only relevant when the script can legitimately fail (marketing tags, optional embeds).
Rule of thumb: every request the canvas iframe makes that is not to the Studio SDK or your app’s own bundles is a candidate for elimination. If it doesn’t need to run under Studio’s iframe, don’t run it.
Live Preview State Resets Mid-Request: Shared Stack Instance Hazard
Symptom: resolving one composition/URL makes several internal CDA/CMA calls (a literal-URL search, a candidate-ranking search, an entry lookup), and only the FIRST of them actually uses Live Preview (rest-preview.contentstack.com, populated live_preview header, drafts visible). The rest silently fall back to the plain Delivery API (cdn.contentstack.io, empty live_preview header, published-only), with no error and no code difference between the calls. Net effect: an unpublished/draft-only composition resolves on the lucky first attempt and 404s (“Composition not found for URL: …”) the moment resolution needs a second internal query — which is the common case for anything but an exact literal-URL match.
Root cause: stack.livePreviewQuery(...) does not scope to one call. It mutates a single shared object living on the stack instance itself — _client.stackConfig.live_preview in @contentstack/delivery-sdk (node_modules/@contentstack/delivery-sdk/dist/modern/lib/stack.js), the equivalent field on the legacy contentstack v3 SDK. The actual host-switch happens centrally, in @contentstack/core‘s request layer:
// @contentstack/core/dist/esm/src/lib/request.js
if (instance.stackConfig && instance.stackConfig.live_preview) {
const livePreviewParams = instance.stackConfig.live_preview;
if (livePreviewParams.preview_token) {
instance.defaults.headers.live_preview = livePreviewParams.live_preview;
}
if (livePreviewParams.enable && livePreviewParams.live_preview !== 'init') {
url = 'https://' + livePreviewParams.host + url; // only switches host here
}
}
This reads whatever the shared stackConfig.live_preview object currently holds, at the moment each individual HTTP call fires — not a value scoped to the higher-level operation that started the fetch.
The collision: this is only a problem when TWO code paths share the SAME stack instance and have DIFFERENT live-preview intentions on the SAME request. This is exactly the shape of install-studio‘s minimal-add pattern (Studio’s stackSdk reusing an existing app’s stack). Concretely, in a Next.js App Router app:
- Studio’s SDK (@contentstack/studio-client) reads the hash param from the incoming request’s searchQuery and calls stackSdk.livePreviewQuery(hash) before its first internal query — correctly switches that ONE call to the preview host.
- A parent layout on the SAME request does its own unrelated data fetch (e.g. homepage data for SEO metadata) via a helper that calls stack.livePreviewQuery(live_preview ?? {}) with no override — a perfectly reasonable thing for THAT call to want (metadata generation arguably should reflect published state, not an in-progress draft). But since query.live_preview is falsy, the delivery-sdk’s own livePreviewQuery takes its reset branch and explicitly sets live_preview: "" on the SHARED object.
- Next.js runs a page’s layout(s) and the page itself concurrently on one request. That reset can land in the gap between Studio’s first and second internal queries, wiping the hash Studio just set before its next call goes out.
Neither call site is “wrong” in isolation — each wants the correct behavior for its own purpose. The bug is architectural: the API conflates “no live preview for my call” with “no live preview for anyone sharing this object right now,” because state lives on a mutable singleton instead of being scoped per-call.
Check first: confirm it empirically rather than reasoning from source alone. Patch Node’s http/https layer to log the live_preview header on every outgoing Contentstack request (must be added before any Contentstack package’s module graph loads, so restart the dev server after adding it, not hot-reload):
// lib/debug-fetch-interceptor.js — diagnostic only, delete when done
import http from "node:http";
import https from "node:https";
const CS_HOST_PATTERN = /contentstack\.(io|com)/;
function patch(mod) {
if (mod.__csPatched) return;
mod.__csPatched = true;
const originalRequest = mod.request;
mod.request = function (...args) {
const req = originalRequest.apply(this, args);
const host = req.getHeader?.("host") || req.host;
if (host && CS_HOST_PATTERN.test(host)) {
console.log(`[cs-http] -> ${req.method} https://${host}${req.path}`);
console.log("[cs-http] live_preview header:", req.getHeader?.("live_preview"));
}
return req;
};
}
if (!globalThis.__csHttpPatched) {
globalThis.__csHttpPatched = true;
patch(http);
patch(https);
}
Import it for side effects at the top of the route under test, restart the dev server (not hot-reload — module-load order matters), and trigger the failing resolution. If the logged live_preview header is populated on the first call and empty on later ones for the SAME request, this is the cause. Then grep for every other call site sharing the same stack instance: grep -rn "livePreviewQuery(" src/ app/ lib/.
Fix: give Studio’s stackSdk its own independent stack instance, separate from whatever stack the rest of the app reuses for its own content fetching. Reuse the same credentials/env vars to construct it — just not the same object. This removes the collision entirely: the rest of the app can keep calling .livePreviewQuery({}) for its own reasons on its own stack, and Studio’s hash stays untouched because it’s a different instance. See install-studio § Existing Contentstack app? Take the minimal-add branch.
Common Pitfalls
| Pitfall | Why it bites | Fix |
|---|---|---|
| Guessing the cause from prior probability instead of running the diagnostic | Wastes user time. The same symptom has different causes in different setups | Always run the “Check first” diagnostic before proposing a fix |
| Treating “canvas blank” and “canvas shows host home page” as the same symptom | Blank = wrong URL. Home page = missing route. Opposite fixes. | Look at the actual iframe contents before classifying |
| Suggesting npm install as a generic fix | Hides the real issue when the user has a stale .env or wrong Canvas URL | Only suggest npm-level fixes when the diagnostic actually finds a missing package |
| Recommending the user re-paste credentials when the Canvas URL is the real problem | Touching .env is no-op if credentials weren’t the issue. Wastes the user’s time | Check the Canvas URL config FIRST before recommending any credential change |
| Diagnosing “No Studio Code on This Route” as a routing/registration gap | The banner reflects a broken iframe response, which a local build/syntax error also produces. Chasing Studio wiring wastes time when the real cause is a compile error | Load the URL directly in a plain tab first — see it your app, or a build-error overlay? |
| Treating an intermittent Live-Preview failure as a bad token or Studio misconfiguration | Same token, same hash, same request — it’s a shared-stack race, not a credentials problem. Re-pasting tokens or reconfiguring Studio does nothing | Check whether Studio’s stackSdk shares a stack instance with the rest of the app; see § Live Preview state resets mid-request |
See Also
- docs/setup/troubleshoot-common-studio-issues.md: the canonical reference of symptoms and their fixes (read this if the matrix above doesn’t fit)
- verify-setup: layered smoke test, run this if you’re not sure WHICH layer is failing
- install-studio, install-live-preview: for actual reinstall steps
- register-component: for “Component Loading Error” cases involving registered components