Studio Docs

When to Use

Install and wire @contentstack/live-preview-utils standalone (without the full Studio SDK) so an existing app can receive real-time edits from Contentstack into Visual Editor.

Use when the user wants ONLY Live Preview added to an existing Contentstack app, typically rolling out Visual Editor before adopting Studio. E.g. “I just want Live Preview, not Studio yet”. Do NOT use for full Studio install (use install-studio, it includes LP). Do NOT use to fix LP in an already-installed app (use troubleshoot-canvas).

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. 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.

Install Live Preview Standalone

Context

Live Preview is the post-message channel between Contentstack and a running site. When an editor changes a field, the site re-fetches and re-renders within ~1 second.

Two separate things must be true for Live Preview to work:

  1. Stack-level flag: to enable Live Preview for the stack, open Settings, go to Visual Experience, and open the General tab. This is a single checkbox. It’s stack-wide, not per-CT.
  2. App-level install: @contentstack/live-preview-utils is installed, ContentstackLivePreview.init({…}) is called once at app shell, and the Delivery SDK is wired with live_preview: { enable, preview_token, host }.

Most “Live Preview broken” issues are one of those two missing. This skill ensures both.

Preview Token vs Delivery Token: the Preview Token is paired with a Delivery Token (same screen, same record). It’s NOT a separate token type. When you create a Delivery Token, Contentstack generates the Preview Token at the same time. They share an environment scope.

Reference: docs/setup/app-prerequisites/install-live-preview.md.

Task

  1. HARD GATE: stack-level Live Preview must be ON. Without the stack-level flag no preview_token resolves content, because the channel does not exist.

    Check it yourself instead of asking, the state is readable:

    curl -s -H "api_key: <stack>" -H "$CS_AUTH" \
      "https://<cma-host>/v3/stacks/settings" | jq .stack_settings.live_preview
    # {"enabled": true, "default-env": "blt…", …}   → gate satisfied
    # {}  or  enabled:false                          → not enabled yet

    If it is off, enable it by API: POST /v3/stacks/settings, and mint the preview token with POST /v3/stacks/delivery_tokens?create_with_preview_token=true. Both are scriptable. See enable-visual-experience § Do it by API for the payloads and the three traps (the PUT /v3/stacks no-op, the query-param-not-body rule, and the required branch scope).

    Only fall back to walking the user through the UI when the write 401s with a freshly refreshed credential: an expired OAuth access token 401s identically, and raw curl never refreshes (authenticate-cma). Never assume the user has a session authtoken to paste. Never accept “I think it’s on” as evidence. Read it.

  2. Install the package. Detect package manager from lockfiles:

    • pnpm-lock.yaml: run pnpm add @contentstack/live-preview-utils
    • yarn.lock: run yarn add @contentstack/live-preview-utils
    • default: run npm install @contentstack/live-preview-utils
  3. Write Preview Token to .env.local (or whatever local-only env file the framework uses, which is .env.local for Vite, Next and CRA). Use the prefix the framework requires:

    • Vite: VITE_CS_PREVIEW_TOKEN=…
    • Next: NEXT_PUBLIC_CS_PREVIEW_TOKEN=… (only if used client-side. Otherwise no prefix and server-only)
    • CRA: REACT_APP_CS_PREVIEW_TOKEN=…

    Confirm .env.local is in .gitignore before writing.

  4. Update the existing Delivery SDK init to include the live_preview block. Find the file that currently calls contentstack.stack({ apiKey, deliveryToken, environment }). Derive the preview host from the region, do not hardcode rest-preview.contentstack.com. It’s the US host only. Every other region 401s against it. The map below covers prod regions only. Non-prod (the non-prod domain your team uses) has no region shortcut: every host is <env>-<service>.<non-prod-domain> and must be set explicitly. The full table is install-studio § Non-prod hosts. Establish which data center you’re on first via analyze-project-fit § 2b: Determine the data center.

    // Region → preview host. The US default is the ONLY region that uses the bare
    // `rest-preview.contentstack.com` host; every other region prefixes it.
    const PREVIEW_HOSTS = {
      us:       "rest-preview.contentstack.com",
      eu:       "eu-rest-preview.contentstack.com",
      "azure-na": "azure-na-rest-preview.contentstack.com",
      "azure-eu": "azure-eu-rest-preview.contentstack.com",
      "gcp-na": "gcp-na-rest-preview.contentstack.com",
      "gcp-eu": "gcp-eu-rest-preview.contentstack.com",
      au:       "au-rest-preview.contentstack.com",
    } as const;
    
    const region = (import.meta.env.VITE_CS_REGION ?? "us") as keyof typeof PREVIEW_HOSTS;
    // Non-prod wins over the map, which is prod-only: set VITE_CS_PREVIEW_HOST to
    // <env>-rest-preview.<non-prod-domain>, and VITE_CS_CDA_HOST alongside it.
    const previewHost = import.meta.env.VITE_CS_PREVIEW_HOST || PREVIEW_HOSTS[region];
    if (!previewHost) throw new Error(`Unknown region ${region}; on non-prod set VITE_CS_PREVIEW_HOST`);
    
    const stack = contentstack.stack({
      apiKey:         import.meta.env.VITE_CS_API_KEY,
      deliveryToken:  import.meta.env.VITE_CS_DELIVERY_TOKEN,
      environment:    "preview",
      ...(import.meta.env.VITE_CS_CDA_HOST
        ? { host: import.meta.env.VITE_CS_CDA_HOST }   // non-prod
        : { region }),                                 // prod
      live_preview: {
        enable:        true,
        preview_token: import.meta.env.VITE_CS_PREVIEW_TOKEN,
        host:          previewHost,
      },
    });

    Add VITE_CS_REGION=<region> (or NEXT_PUBLIC_CS_REGION / REACT_APP_CS_REGION) to .env.local so the host is derived at build/runtime, not hardcoded. Default to us when unset. On non-prod, set VITE_CS_CDA_HOST and VITE_CS_PREVIEW_HOST instead: region has no value that reaches those data centers, and leaving it to default sends every read to US prod.

  5. Wire ContentstackLivePreview.init({…}) at app shell. This is the post-message listener that picks up Contentstack’s edit events. Call it once, before any route renders:

    import ContentstackLivePreview from "@contentstack/live-preview-utils";
    
    ContentstackLivePreview.init({
      stackSdk: stack,
      enable: true,
      ssr: false,    // set true for Next.js / Remix / Astro server-side rendering
      mode: "builder",
    });

    For Next.js / Remix / Astro / any SSR framework, set ssr: true. For Vite/CRA/single-page apps, leave ssr: false.

  6. Verify data-cslp tags are emitted. Open any page with a content binding, then open the Elements tab in DevTools. Look for data-cslp="<ct_uid>.<entry_uid>.<locale>.<field>" on every bound DOM element. If absent, the binding isn’t actually using the Delivery SDK output: the app is hardcoding strings instead of reading from CS.

  7. Wire onEntryChange so the running page refetches on edit. ContentstackLivePreview.init opens the channel. onEntryChange(cb) subscribes a callback that fires every time an author saves an edit. Without this, the channel is open but nothing tells your app to re-render: visitors see stale content until a manual reload. Wire it once, in an app-shell client component, using the loop-safe pattern below.

    The callback body depends on how you render Studio compositions:

    • CSR (useCompositionData hook): call the hook’s refetchSpec.
    • SSR / RSC (Next.js App Router): call router.refresh().
    • SSR (Next.js Pages Router): call router.replace(router.asPath, undefined, { scroll: false }).
    • SSR (Remix): call the route’s revalidator.revalidate().
    • Non-Studio pages that just read Delivery-SDK content: call your own refetch / cache-invalidate.

    Loop-safe pattern (mandatory). onEntryChange invokes the callback ONCE at register time in addition to on every edit. If you subscribe inside a useEffect(..., [router]) and the callback calls router.replace / router.refresh, the router identity changes, so the effect cleans up and re-subscribes, the register-time fire runs again, and the result is an infinite refetch loop. Always: subscribe once with [] deps, keep the router in a useRef, skip the first (register-time) fire, unsubscribe in cleanup.

    "use client";
    import { useEffect, useRef } from "react";
    import { useRouter } from "next/navigation"; // or next/router for Pages Router
    import ContentstackLivePreview from "@contentstack/live-preview-utils";
    
    export function LivePreviewBridge() {
      const router = useRouter();
      const routerRef = useRef(router);
      routerRef.current = router;                       // latest router without re-subscribing
      useEffect(() => {
        let unsub: any, firstFire = true;
        unsub = ContentstackLivePreview.onEntryChange(() => {
          if (firstFire) { firstFire = false; return; } // skip register-time fire
          routerRef.current.refresh();                  // App Router
          // Pages Router:  routerRef.current.replace(routerRef.current.asPath, undefined, { scroll: false });
          // CSR:            refetchRef.current?.();
        });
        return () => {
          if (unsub != null) ContentstackLivePreview.unsubscribeOnEntryChange?.(unsub);
        };
      }, []);                                            // SUBSCRIBE ONCE — never depend on `router`
      return null;
    }

    Reference implementation: the SDK’s own composable-studio-sdk/test-resources/ssr-react/hooks/use-studio-spec-options.ts uses the same skipFirstClientFetchRef pattern.

  8. Live-test the channel. Open the page in a browser. In another tab, open Contentstack and edit a field on the entry the page renders. The page should update within ~1s without a manual reload. If the Network tab shows the same route data endpoint (_next/data/<id>/<route>.json or the RSC payload) firing repeatedly for a single edit, your onEntryChange wiring isn’t loop-safe. See step 7.

Inputs Needed From the User

  1. stackApiKey: used in stack({ apiKey }).
  2. previewToken: written to .env.local, used in live_preview.preview_token.
  3. environment: must match a stack environment.
  4. region: selects the host string.

Don’t ask for Delivery Token here: it’s already configured (Live Preview is being added to an existing app that has the Delivery SDK).

Acceptance

  • @contentstack/live-preview-utils appears in package.json dependencies.
  • .env.local has the Preview Token under the correct framework prefix.
  • The existing Delivery SDK init now includes live_preview: { enable: true, preview_token, host }.
  • ContentstackLivePreview.init({…}) is called exactly once at app shell.
  • Stack-level Live Preview is confirmed ON, read back from GET /v3/stacks/settings (live_preview.enabled === true), not taken on trust.
  • The Studio SDK init sets cslp: { appendTags: true }: the renderer gates both attribute bags on it, so without it no element carries a tag however the components are written. See install-studio.
  • At least one bound DOM element shows a data-cslp attribute in DevTools.
  • ContentstackLivePreview.onEntryChange(...) is wired loop-safely: subscribe-once (useEffect(..., [])), router held in a useRef, register-time fire skipped, cleanup calls unsubscribeOnEntryChange. Never [router] deps.
  • Editing a field in Contentstack updates the running app within ~1s.
  • Network tab shows exactly ONE refetch per edit (not a hundreds-of-requests loop).

Common Pitfalls

PitfallWhy it bitesFix
Treating Preview Token as a separate token typeIt doesn’t exist independently: it’s always paired with a Delivery TokenOn the Delivery Token screen, look for “Preview Token” field below the Delivery Token. They share a record.
Forgetting the stack-level Live Preview flagApp is wired correctly but nothing flows because the stack-side channel is offIn the stack, open Settings, go to Visual Experience, open General, and toggle it ON before anything else
Using the wrong host stringToken and region mismatch produces 401 or empty responsesUse the region map above. Do NOT default to rest-preview.contentstack.com blindly
Calling init() per-route instead of at app shellListeners stack, messages process N times, performance degradesSingle init() call before first render, then never again
ssr: false in Next.js / RemixInitial render misses preview data. Only hydration fixes itSet ssr: true for any SSR framework
onEntryChange wired in a [router]-dep effectInfinite _next/data/<id>/<route>.json (Pages) or RSC payload (App) refetch, hundreds of requests per editSubscribe ONCE with empty deps, hold router in a useRef, skip the first (register-time) fire, unsubscribe in cleanup. Full pattern in step 7.
onEntryChange never wiredChannel opens but page never updates on edit. Author sees stale contentWire it in an app-shell client component (step 7). Applies to CSR AND SSR: SSR only “requires” it if Live Preview is enabled on that route. If a route has no Live Preview at all, skip both ContentstackLivePreview.init and onEntryChange.
Committing Preview Token to gitToken leakAlways to .env.local (in .gitignore). Never in source

See Also

  • docs/setup/app-prerequisites/install-live-preview.md: full reference
  • install-studio: full Studio install which includes Live Preview (use this if the user wants Studio too)
  • verify-setup: Layer 2 of the smoke test checks the Live Preview channel
  • troubleshoot-canvas: “Live Preview not configured” symptom