install-live-preview
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: 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.
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:
- 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.
- App-level install:
@contentstack/live-preview-utilsis installed,ContentstackLivePreview.init({…})is called once at app shell, and the Delivery SDK is wired withlive_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/10-set-up/05-install-studio/30-install-live-preview.md.
Task
-
HARD GATE: stack-level Live Preview must be ON. Without the stack-level flag no
preview_tokenresolves 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 yetIf it is off, enable it by API:
POST /v3/stacks/settings, and mint the preview token withPOST /v3/stacks/delivery_tokens?create_with_preview_token=true. Both are scriptable. Seeenable-visual-experience§ Do it by API for the payloads and the three traps (thePUT /v3/stacksno-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
curlnever refreshes (authenticate-cma). Never assume the user has a sessionauthtokento paste. Never accept "I think it's on" as evidence. Read it. -
Install the package. Detect package manager from lockfiles:
pnpm-lock.yaml: runpnpm add @contentstack/live-preview-utilsyarn.lock: runyarn add @contentstack/live-preview-utils- default: run
npm install @contentstack/live-preview-utils
-
Write Preview Token to
.env.local(or whatever local-only env file the framework uses, which is.env.localfor 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.localis in.gitignorebefore writing. - Vite:
-
Update the existing Delivery SDK init to include the
live_previewblock. Find the file that currently callscontentstack.stack({ apiKey, deliveryToken, environment }). Derive the preview host from the region, do not hardcoderest-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 noregionshortcut: every host is<env>-<service>.<non-prod-domain>and must be set explicitly. The full table isinstall-studio§ Non-prod hosts. Establish which data center you're on first viaanalyze-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 }), // prodlive_preview: {enable: true,preview_token: import.meta.env.VITE_CS_PREVIEW_TOKEN,host: previewHost,},});Add
VITE_CS_REGION=<region>(orNEXT_PUBLIC_CS_REGION/REACT_APP_CS_REGION) to.env.localso the host is derived at build/runtime, not hardcoded. Default touswhen unset. On non-prod, setVITE_CS_CDA_HOSTandVITE_CS_PREVIEW_HOSTinstead:regionhas no value that reaches those data centers, and leaving it to default sends every read to US prod. -
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 only with a preview token: the package draws its Edit button on import, even without init().if (previewToken) {import("@contentstack/live-preview-utils").then(({ default: ContentstackLivePreview }) =>ContentstackLivePreview.init({stackSdk: stack,enable: true,ssr: false, // set true for Next.js / Remix / Astro server-side renderingmode: "builder",}));}For Next.js / Remix / Astro / any SSR framework, set
ssr: true. For Vite/CRA/single-page apps, leavessr: false. -
Verify
data-cslptags are emitted. Open any page with a content binding, then open the Elements tab in DevTools. Look fordata-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. -
Wire
onEntryChangeso the running page refetches on edit.ContentstackLivePreview.initopens 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 (
useCompositionDatahook): call the hook'srefetchData— the bound data is all a live edit changes. - 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).
onEntryChangeinvokes the callback ONCE at register time in addition to on every edit. If you subscribe inside auseEffect(..., [router])and the callback callsrouter.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 auseRef, 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 Routerexport function LivePreviewBridge() { // render it only when a preview token is setconst router = useRouter();const routerRef = useRef(router);routerRef.current = router; // latest router without re-subscribinguseEffect(() => {let unsub: any, firstFire = true, LP: any;import("@contentstack/live-preview-utils").then(({ default: m }) => { LP = m; unsub = m.onEntryChange(() => {if (firstFire) { firstFire = false; return; } // skip register-time firerouterRef.current.refresh(); // App Router// Pages Router: routerRef.current.replace(routerRef.current.asPath, undefined, { scroll: false });// CSR: refetchRef.current?.();}); });return () => {if (unsub != null) LP?.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.tsuses the sameskipFirstClientFetchRefpattern. - CSR (
-
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>.jsonor the RSC payload) firing repeatedly for a single edit, youronEntryChangewiring isn't loop-safe. See step 7.
Inputs needed from the user
stackApiKey: used instack({ apiKey }).previewToken: written to.env.local, used inlive_preview.preview_token.environment: must match a stack environment.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-utilsappears inpackage.jsondependencies. -
.env.localhas 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. Seeinstall-studio. - At least one bound DOM element shows a
data-cslpattribute in DevTools. -
ContentstackLivePreview.onEntryChange(...)is wired loop-safely: subscribe-once (useEffect(..., [])), router held in auseRef, register-time fire skipped, cleanup callsunsubscribeOnEntryChange. 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
| Pitfall | Why it bites | Fix |
|---|---|---|
| Treating Preview Token as a separate token type | It doesn't exist independently: it's always paired with a Delivery Token | On the Delivery Token screen, look for "Preview Token" field below the Delivery Token. They share a record. |
| Forgetting the stack-level Live Preview flag | App is wired correctly but nothing flows because the stack-side channel is off | In the stack, open Settings, go to Visual Experience, open General, and toggle it ON before anything else |
| Using the wrong host string | Token and region mismatch produces 401 or empty responses | Use the region map above. Do NOT default to rest-preview.contentstack.com blindly |
Calling init() per-route instead of at app shell | Listeners stack, messages process N times, performance degrades | Single init() call before first render, then never again |
ssr: false in Next.js / Remix | Initial render misses preview data. Only hydration fixes it | Set ssr: true for any SSR framework |
onEntryChange wired in a [router]-dep effect | Infinite _next/data/<id>/<route>.json (Pages) or RSC payload (App) refetch, hundreds of requests per edit | Subscribe 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 wired | Channel opens but page never updates on edit. Author sees stale content | Wire 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 git | Token leak | Always to .env.local (in .gitignore). Never in source |
See also
docs/10-set-up/05-install-studio/30-install-live-preview.md: full referenceinstall-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 channeltroubleshoot-canvas: "Live Preview not configured" symptom