---
name: 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`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate.

# 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/10-set-up/05-install-studio/30-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:

   ```bash
   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](enable-visual-experience.md#api-path) 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`](authenticate-cma.md)). 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`](install-studio.md) § Non-prod hosts. Establish which data center you're on first via [`analyze-project-fit`](analyze-project-fit.md) § 2b: Determine the data center.

   ```ts
   // 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:

   ```ts
   // 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 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 `refetchData` — 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).** `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.**

   ```tsx
   "use client";
   import { useEffect, useRef } from "react";
   import { useRouter } from "next/navigation"; // or next/router for Pages Router

   export function LivePreviewBridge() {   // render it only when a preview token is set
     const router = useRouter();
     const routerRef = useRef(router);
     routerRef.current = router;                       // latest router without re-subscribing
     useEffect(() => {
       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 fire
         routerRef.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.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`](install-studio.md).
- [ ] 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

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