# The Ultimate Guide to Contentstack Visual Building

- **Authors:** Tim Benniks, Lo Etheridge
- **Published:** 2026-03-31T22:00:00.000Z
- **Updated:** 2026-04-02T11:55:13.543Z
- **Tags:** content_modeling, visual_editing, headless, sdk
- **Chapters:** 8
- **Source:** [https://developers.contentstack.com/guides/the-ultimate-guide-to-contentstack-visual-building](https://developers.contentstack.com/guides/the-ultimate-guide-to-contentstack-visual-building)

---

## Table of Contents

1. [How Live Preview works](#how-live-preview-works)
2. [Client-Side Rendering](#client-side-rendering)
3. [Server-Side Rendering](#server-side-rendering)
4. [Static Site Generation](#static-site-generation)
5. [Middleware and Database-Backed Architectures](#middleware-and-database-backed-architectures)
6. [Edit Tags and Visual Builder](#edit-tags-and-visual-builder)
7. [Debugging, Pitfalls, and Best Practices](#debugging-pitfalls-and-best-practices)
8. [AI Agent Playbook for Live Preview and Visual Builder](#ai-agent-playbook-for-live-preview-and-visual-builder)

Everything you need to build bulletproof real-time preview experiences.

Live Preview creates a continuous feedback loop where editors see their changes materialize instantly. This guide gives you the mental models and practical knowledge to implement it correctly, debug it when things break, and adapt it to any rendering strategy.

## What You'll Build

This guide uses the [Contentstack Next.js Kickstart](https://github.com/contentstack/kickstart-next) as a running example. For a variant that routes all content through a middleware API layer (keeping tokens server-side), see the [Next.js Middleware Kickstart](https://github.com/contentstack/kickstart-next-middleware). By the end, you'll understand every line across these four files.

lib/contentstack.ts configures the SDK, initializes Live Preview, and fetches content:

```typescript
import contentstack, { QueryOperation } from "@contentstack/delivery-sdk";
import ContentstackLivePreview, { IStackSdk } from "@contentstack/live-preview-utils";

// Delivery SDK instance: when the live preview hash is present, requests go to the Preview API (drafts).
export const stack = contentstack.stack({
  apiKey: process.env.NEXT_PUBLIC_CONTENTSTACK_API_KEY as string,
  deliveryToken: process.env.NEXT_PUBLIC_CONTENTSTACK_DELIVERY_TOKEN as string,
  environment: process.env.NEXT_PUBLIC_CONTENTSTACK_ENVIRONMENT as string,
  live_preview: {
    enable: true,
    preview_token: process.env.NEXT_PUBLIC_CONTENTSTACK_PREVIEW_TOKEN,
    host: "rest-preview.contentstack.com",
  },
});

// Run in the browser only. Wires the iframe session to your stack and enables Visual Builder chrome.
export function initLivePreview() {
  ContentstackLivePreview.init({
    ssr: false, // CSR: refetch in place instead of full reloads
    mode: "builder",
    stackSdk: stack.config as IStackSdk, // So the SDK can inject hash + content type context
    stackDetails: { apiKey: "...", environment: "..." },
    editButton: { enable: true },
  });
}

export async function getPage(url: string) {
  const result = await stack
    .contentType("page").entry().query()
    .where("url", QueryOperation.EQUALS, url)
    .find();
  const entry = result.entries?.[0];
  // Populates `entry.$` with data-cslp props for click-to-edit in preview
  if (entry) contentstack.Utils.addEditableTags(entry, "page", true);
  return entry;
}
```

components/Preview.tsx runs the Live Preview loop, refetching content on every editor change:

```typescript
"use client";
import { useState, useEffect, useCallback } from "react";
import ContentstackLivePreview from "@contentstack/live-preview-utils";
import { getPage, initLivePreview } from "@/lib/contentstack";
import Page from "./Page";

export default function Preview({ path }: { path: string }) {
  const [page, setPage] = useState();
  const getContent = useCallback(async () => {
    setPage(await getPage(path)); // Preview API returns latest draft for current hash
  }, [path]);

  useEffect(() => {
    initLivePreview();
    // Fires on every editor change in the stack UI → refetch → re-render
    ContentstackLivePreview.onEntryChange(getContent);
  }, [path]);

  if (!page) return <p>Loading...</p>;
  return <Page page={page} />;
}
```

components/Page.tsx renders content with edit tags so editors can click any element to jump to its field:

```typescript
import { VB_EmptyBlockParentClass } from "@contentstack/live-preview-utils";

export default function ContentDisplay({ page }: { page: Page | undefined }) {
  return (
    <main>
      {/* Spreads add data-cslp (and related attrs) so Visual Builder can target each field */}
      <h1 {...page.$?.title}>{page?.title}</h1>

      {/* VB_EmptyBlockParentClass marks the modular-blocks container when empty so VB can add blocks */}
      <div className={`blocks ${VB_EmptyBlockParentClass}`} {...page.$?.blocks}>
        {page?.blocks?.map((item, index) => (
          <section key={index} {...page.$?.[`blocks__${index}`]}>
            <h2 {...item.block.$?.title}>{item.block.title}</h2>
          </section>
        ))}
      </div>
    </main>
  );
}
```

app/page.tsx routes between preview and production:

```typescript
import { getPage, isPreview } from "@/lib/contentstack";
import Page from "@/components/Page";
import Preview from "@/components/Preview";

export default async function Home() {
  // Preview session: client wrapper subscribes to Live Preview; production: one server fetch
  if (isPreview) return <Preview path="/" />;
  const page = await getPage("/");
  return <Page page={page} />;
}
```

The first file connects to Contentstack and sets up preview credentials. The second subscribes to editor changes and refetches on every keystroke. The third renders content with data-cslp edit tags and VB_EmptyBlockParentClass for empty modular blocks. The fourth decides which rendering path to use.

## Who This Guide Is For

- Frontend developers implementing Live Preview across CSR, SSR, or SSG architectures


- Full-stack engineers working with middleware, BFFs, and database-backed content systems


- Technical architects evaluating Live Preview for enterprise deployments


- Anyone debugging a Live Preview issue who wants to isolate problems systematically



## Guide Structure

### Foundations

- [1. How Live Preview Works](/guides/the-ultimate-guide-to-contentstack-visual-building/how-live-preview-works) - The mental model, architecture, session lifecycle, and APIs



### Rendering Strategies

- [2. Client-Side Rendering](/guides/the-ultimate-guide-to-contentstack-visual-building/client-side-rendering) - SDK setup, subscriptions, and refetch patterns for SPAs


- [3. Server-Side Rendering](/guides/the-ultimate-guide-to-contentstack-visual-building/server-side-rendering) - Reload-based preview, request-scoped clients, hash propagation


- [4. Static Site Generation](/guides/the-ultimate-guide-to-contentstack-visual-building/static-site-generation) - Preview mode, framework escape hatches


- [5. Middleware and Complex Architectures](/guides/the-ultimate-guide-to-contentstack-visual-building/middleware-and-database-backed-architectures) - BFF, edge middleware, database caching



### Advanced Features

- [6. Edit Tags and Visual Builder](/guides/the-ultimate-guide-to-contentstack-visual-building/edit-tags-and-visual-builder) - Click-to-edit, field paths, Visual Builder integration



### Operations

- [7. Debugging and Best Practices](/guides/the-ultimate-guide-to-contentstack-visual-building/debugging-pitfalls-and-best-practices) - Systematic debugging, common pitfalls, checklists



## Prerequisites

Before diving in, you should have:

- A working Contentstack stack with content types and entries


- A frontend application (any framework) that fetches and renders Contentstack content


- Basic familiarity with your framework's data fetching and rendering


- Access to your stack's Settings to enable Live Preview and generate preview tokens



### Enabling Live Preview in Your Stack

Enable Live Preview in your stack settings, set a default preview environment, and add base URLs for each locale in your environment configuration. For step-by-step instructions, see the [official setup guide](https://www.contentstack.com/docs/developers/set-up-live-preview/set-up-live-preview-for-your-website).

Optionally, enable the "Display Setup Status" toggle for real-time configuration feedback during setup. Enable "Always Open in New Tab" if you run SDK v4.0.0+ to preview outside the iframe.

## What's Next

You now have the prerequisites in place and basic configuration done. Next, lets take a quick deep dive into how Live Preview works and how it works with different rendering strategies. Proceed to [How Live Preview Works](/guides/the-ultimate-guide-to-contentstack-visual-building/how-live-preview-works) to get started.



---

## How Live Preview works

What you'll learn in this chapter:

- Describe the three participants in a Live Preview session and the role each plays

- Explain why change events carry no payload and why your site must always refetch


- Trace the full lifecycle of a preview hash from session creation to invalidation


- Distinguish Preview API from Delivery API and know when to use each



## Why this matters

Most Live Preview bugs trace back to a misunderstanding of the architecture. The mental model in this chapter prevents an entire class of problems: stale previews, broken navigation, cached drafts.

Live Preview is a coordinated, session-scoped conversation between three participants: the CMS where editors work, your running website, and the preview services that serve draft content. The CMS signals that something changed, your site decides how to re-render, and the preview services authorize and return draft data on demand.

This chapter covers the architecture, the session model, the APIs, and the communication protocol. Every concept is explained once here; the rendering strategy chapters that follow build on this foundation.

## The Mental Model

When an editor opens an entry with Live Preview enabled, three concurrent sessions begin:

The content editing session lives inside the entry editor. The CMS tracks every keystroke and field change, maintaining the authoritative state of draft content.

The preview rendering session runs in your site, loaded in an iframe within the CMS or in a separate browser tab. Your site initializes the Live Preview SDK, establishes communication with the CMS, and renders content using your normal rendering pipeline.

The data access session operates through the Preview API, which serves draft content scoped by a preview token and a live preview hash. The hash acts as a session identifier, preventing draft content from leaking across sessions.

These sessions are tightly coordinated but loosely coupled. The CMS doesn't need to know how your site renders. Your site doesn't need to understand the CMS's internal state. The Preview API doesn't care about your framework.

### The Flow of a Single Edit

An editor changes the About page headline from "Our Story" to "Our Mission" and tabs out of the field:

- Session establishment: The CMS creates a Live Preview session and generates a unique hash


- Site loading: Your site loads with the hash in the URL as a query parameter, along with content type UID, entry UID, and locale


- Handshake: The SDK sends an initialization message to the CMS, the CMS acknowledges, and the communication channel opens


- Change notification: The CMS emits a "content changed" event. This event does not contain the updated content - it only signals that something changed


- Refetch and re-render: Your site fetches draft content from the Preview API using the current hash, then re-renders



sequenceDiagram

  participant CMS as Contentstack CMS

  participant Site as Website

  participant Preview as Preview Services

  Note over CMS: 1. CMS creates session + hash

  CMS->>Site: Loads site with hash

  Note over Site: 2. Site initializes SDK<br/>Handshake ready

  Note over CMS: 3. CMS signals change event

  CMS->>Site: postMessage

  Note over Site: 4. Site refetches draft data

  Site->>Preview: Preview API request

  Note over Preview: 5. Preview services return draft

  Preview-->>Site: Draft response

  Note over Site: 6. Site re-renders<br/>Updated preview

Every edit follows this same pattern. The simplicity is deliberate - it makes the system predictable across frameworks.

### Two Architectural Consequences

Live Preview never bypasses your data layer. It always forces a refetch, so your rendering path stays honest. If your site has a bug, Live Preview exposes it rather than hiding it.

Live Preview never pushes content into the DOM. The CMS doesn't know your component structure, doesn't manipulate your state, and doesn't inject HTML. Your site receives a signal and decides what to do with it. Problems in preview almost always trace back to how you fetch and cache content, not to the CMS or SDK.

## The Five Components

### 1. Contentstack CMS (The Orchestrator)

- Hosts the entry editor where content modifications occur


- Detects changes as editors type, save, or publish


- Generates and rotates the live preview hash


- Loads your site in an iframe panel or opens it in a new browser tab


- Emits change events via postMessage



The CMS never pushes draft content directly to your site. It only signals that changes occurred.

### 2. Your Website (The Renderer)

- Renders content using your normal component structure


- Initializes the Live Preview SDK when loaded in preview context


- Listens for change events from the CMS


- Fetches draft content from preview services when appropriate



Your site remains in control of its rendering pipeline.

### 3. The Live Preview SDK (The Mediator)

- Establishes the postMessage handshake between CMS and your site


- Tracks session and hash state


- Adapts behavior for CSR vs SSR rendering models


- Exposes a clean API for subscribing to change events


- Manages the edit button and other UI affordances



The SDK is intentionally minimal. It doesn't fetch content, manage state, or render anything.

### 4. Preview Services (The Draft Data Source)

Preview services serve unpublished content with special authentication:

- Preview token: A credential from your stack settings that authorizes access to draft content


- Live preview hash: The session identifier that scopes which draft content you can access



The default REST preview endpoint is rest-preview.contentstack.com (North America). For other regions (EU, Azure, GCP), see the [region-specific endpoints](https://www.contentstack.com/docs/developers/contentstack-regions/api-endpoints).

### 5. Delivery Services (The Published Data Source)

The production-safe endpoints for published content. Optimized for stability, caching, and performance. Don't require the live preview hash and won't serve draft content.

### Quick Orientation: The Skeleton in Code

Here is how the five components map to the kickstart code from the introduction:

```typescript
// Component 5: Delivery Services + Component 4: Preview Services
import contentstack, { QueryOperation } from "@contentstack/delivery-sdk";

const stack = contentstack.stack({
  apiKey: "...",
  deliveryToken: "...",
  environment: "preview",
  live_preview: {
    enable: true,
    preview_token: "...",
    host: "rest-preview.contentstack.com", // Same SDK switches to this host when hash is active
  },
});

// Component 3: The SDK (Mediator)
import ContentstackLivePreview, {
  IStackSdk,
} from "@contentstack/live-preview-utils";

ContentstackLivePreview.init({
  ssr: false, // CSR loop: subscribe and refetch (see SSR chapter for reload-based flow)mode: "builder",
  stackSdk: stack.config as IStackSdk,
  stackDetails: { apiKey: "...", environment: "..." },
  editButton: { enable: true },
});

// Component 2: Your Website (Renderer)
ContentstackLivePreview.onEntryChange(async () => {
  // Event has no payload  - always query Preview API again for authoritative draftconst result = await stack
    .contentType("page")
    .entry()
    .query()
    .where("url", QueryOperation.EQUALS, "/")
    .find();
  renderPage(result.entries?.[0]);
});
```


flowchart TB
  cms["Contentstack CMS<br/>- Entry editor<br/>- Session + hash<br/>- Change events"]
  sdk["Live Preview SDK<br/>- Handshake<br/>- Event bridge<br/>- Hash updates"]
  site["Your Website<br/>- Render content<br/>- Refetch on event<br/>- Apply edit tags"]
  preview["Preview Services<br/>Draft content<br/>Requires preview token + hash<br/>No caching"]
  delivery["Delivery Services<br/>Published content<br/>Delivery token<br/>Cacheable"]

  cms -->|postMessage| sdk
  sdk -->|Handshake / hash updates| site
  site -->|Preview requests| preview
  site -->|Delivery requests| delivery
  sdk -.->|Preview context| preview
  sdk -.->|Production context| delivery

## Preview API vs Delivery API

The Preview API and Delivery API share the same REST routes, GraphQL schemas, query structure, and response shape. The difference is what content each serves and what authentication each requires.

Delivery API serves published content. Requires a delivery token. Fully cacheable. Use for production.

Preview API serves draft content including unsaved changes. Requires both a preview token AND the live preview hash. Never cacheable - the same request can return different content milliseconds later as the editor types. Use only during active preview sessions.


flowchart TB
  decision{"Hash present?"}
  preview["Preview API<br/>Content: Draft + unpublished<br/>Auth: Preview token + hash<br/>Caching: Never<br/>Scope: Session-scoped"]
  delivery["Delivery API<br/>Content: Published<br/>Auth: Delivery token<br/>Caching: Safe<br/>Scope: Global"]

  decision -->|Yes| preview
  decision -->|No| delivery

This distinction is a trust boundary. Mixing preview and delivery requests in the same flow produces a page with inconsistent data - part published, part draft - that matches neither the editor's view nor the production site.

### Authentication

The Preview API requires two additional credentials on top of the standard API key: a preview_token (from your stack settings) and the live_preview hash (from the current session). Both must be present for draft content to be returned.

```javascript
// Preview API headers: standard credentials + preview-specific ones
{
  "api_key": "your_stack_api_key",
  "access_token": "your_delivery_token",
  "preview_token": "your_preview_token",
  "live_preview": "current_session_hash"
}
```

### The Switch Logic

Centralize the decision. If you have a hash, use preview. If you don't, use delivery.

```typescript
function getContentstackConfig(previewHash) {
  const baseConfig = {
    apiKey: process.env.CONTENTSTACK_API_KEY,
    deliveryToken: process.env.CONTENTSTACK_DELIVERY_TOKEN,
    environment: process.env.CONTENTSTACK_ENVIRONMENT,
  };

  if (previewHash) {
    return {
      ...baseConfig,
      host: "rest-preview.contentstack.com",
      previewToken: process.env.CONTENTSTACK_PREVIEW_TOKEN,
      livePreviewHash: previewHash,
      // No CDN or app cache  - responses are per-session and change continuously
    };
  }

  return {
    ...baseConfig,
    host: "cdn.contentstack.io",
    // Standard delivery: cache-friendly
  };
}
```

## The Session Lifecycle and Hash

A Live Preview session begins when an editor opens an entry and ends when they close it. Everything in between is scoped to a single transient token: the live preview hash.

### What the Hash Is

The hash is a short-lived, session-scoped token that proves:

- A valid preview session exists


- The request is authorized (combined with the preview token)


- The request is scoped to this editing session



The hash is runtime state, not a stack setting or environment variable. It's generated fresh for each editing session, can rotate during long sessions, and becomes invalid when the session ends.

### Strict Rules

- Do not store the hash in a database. It's runtime state, not persistent data.


- Do not place the hash in a long-lived cookie. Cookies persist across sessions. The hash must not.


- Do not cache preview responses keyed by URL. Two requests to the same URL with different hashes return different content.


- Do not share the hash between users. Each editing session has its own hash.



### The Lifecycle

Session creation: Editor opens an entry. CMS generates a unique hash.

Site load: CMS loads your site in an iframe (or new tab) with query parameters: live_preview (hash), content_type_uid, entry_uid, locale.

Handshake: SDK sends init to the CMS, receives init-ack. Communication channel is open.

Steady state: Editor types → CMS emits change event → SDK calls your callback → you refetch and re-render. This loop repeats for every edit.

Session end: Editor closes the entry. Hash becomes invalid. Preview API requests with the old hash fail.


flowchart LR
  create["1. Create<br/>CMS creates session<br/>Hash generated"]
  load["2. Load<br/>Site loads with hash + params"]
  handshake["3. Handshake<br/>SDK init<br/>init / init-ack"]
  steady["4. Steady<br/>Events -> refetch<br/>Re-render loop"]
  finish["5. End<br/>Editor closes<br/>Hash invalid"]
  hash["Hash behavior<br/>- Session-scoped<br/>- Can rotate<br/>- Never persisted"]

  create --> load --> handshake --> steady --> finish
  steady -.-> hash

The session outlives navigation within the preview iframe (clicking links), but it does not outlive the editor's engagement with the entry.

### Hash Rotation

During long editing sessions, the CMS may rotate the hash. Always read the current hash from the SDK (CSR) or from the current request parameters (SSR) rather than storing it at initialization.

## Communication Protocol

Live Preview uses the browser's postMessage API for cross-origin messaging between the CMS and your site (in the iframe). The SDK abstracts this into a clean event model.

### The Event Protocol

- init (Site → CMS): Sent when the SDK initializes. Tells the CMS your site is ready.
- init-ack (CMS → Site): Confirms the channel is open and the handshake is complete.
- client-data-send (CMS → Site): Emitted when content changes. Does not contain the actual content - only signals that something changed. Your site responds by refetching.

### Events Carry Intent, Not Payload

This is a critical design principle. When you receive a change event, it means "something changed" - not "here's the new content." Your site decides what to fetch. Your data flow stays deterministic. Your fetch layer is the source of truth.

Attempting to extract field changes or apply deltas from events will fail. The events don't contain that information.

## Iframe vs New Tab

By default, Live Preview loads your site in an iframe within the CMS entry editor. Starting with SDK v4.0.0, Contentstack also supports opening the preview in a standalone browser tab - enable "Always Open in New Tab" in Settings > Live Preview.

The new-tab mode bypasses iframe restrictions (SSO, OAuth, X-Frame-Options, strict CSP headers). Both modes use the same postMessage communication. The SDK detects the context and adapts.

You can check the current context through ContentstackLivePreview.config.windowType:

- "preview": iframe-based Live Preview or Timeline preview


- "builder": Visual Builder iframe


- "independent": direct browser access



## Key Takeaways

- Three-party system: CMS signals changes, your site refetches, Preview API serves drafts.


- Events carry intent, not payload. Always refetch; never extract content from events.


- The hash is runtime state: session-scoped, never cacheable, never persisted.


- Preview API and Delivery API share the same interface but serve different content. Don't mix them.


- The SDK is a thin mediator: postMessage handshake and session state only.



## Check Your Understanding

- An editor opens an entry, makes a change, and your preview still shows the old content. Based on what you learned about the five components, which boundaries would you check first?


- Why doesn't the CMS push updated content directly into your site's DOM? What would break if it did?


- An editor closes an entry and reopens it five minutes later. Can the site reuse the hash from the previous session? Why or why not?


- Your team proposes caching Preview API responses for 30 seconds to reduce API calls. Why is this unsafe?





---

## Client-Side Rendering

Prerequisites: This chapter builds on [How Live Preview Works](/guides/the-ultimate-guide-to-contentstack-visual-building/how-live-preview-works). You should understand the session lifecycle, the role of the live preview hash, and why change events carry no payload before proceeding.

What you'll be able to do after this chapter:

- Initialize the Live Preview SDK for CSR with the correct configuration
- Subscribe to content changes and implement the refetch-on-change pattern
- Handle multi-entry pages, cleanup, and component lifecycle correctly
- Identify and avoid common CSR pitfalls: stale closures, missing cleanup, and redundant subscriptions

## Why this matters

CSR is the fastest path to a working Live Preview, but small mistakes (wrong subscription point, state merging, missing cleanup) produce subtle bugs. This chapter shows the patterns that work and the mistakes to avoid.

CSR is the most natural fit for Live Preview. Your app is already wired to react to state changes - Live Preview just adds one more source of those changes. Updates happen in place without page reloads.

## The CSR Flow With Live Preview

flowchart TB

  boot["Browser loads HTML/JS"]

  init["App boots + SDK initializes"]

  fetch1["Fetch preview content"]

  render["Render"]

  editor["Editor makes change"]

  event["SDK receives event"]

  callback["Callback fires"]

  refetch["Refetch preview content"]

  rerender["Re-render"]


  boot --> init --> fetch1 --> render

  render --> editor --> event --> callback --> refetch --> rerender

  rerender -.->|repeat for each edit| editor

Everything after SDK initialization happens in the same runtime. Your app never reloads. State management, component trees, and event listeners all stay intact.

If you want to see this in a complete project, the kickstart's Preview.tsx from the introduction is a full CSR Live Preview implementation in under 20 lines.

## SDK Installation

```bash
npm install @contentstack/live-preview-utils @contentstack/delivery-sdk
```

Or include directly in HTML:

```javascript
<script type="module">import ContentstackLivePreview from "https://esm.sh/@contentstack/live-preview-utils@3";
  // Minimal init; add ssr: false and stackSdk when you fetch via the Delivery SDK in the browser

  ContentstackLivePreview.init({
    stackDetails: { apiKey: "your-stack-api-key" },
  });
</script>
```

## SDK Initialization

Initialize once, early in your application lifecycle, and only in browser context. The SDK must be ready before content fetching begins.

```typescript
import ContentstackLivePreview from "@contentstack/live-preview-utils";

ContentstackLivePreview.init({
  enable: true,
  ssr: false, // CSR: subscribe and refetch without full page reloads
stackDetails: {
    apiKey: "your-stack-api-key",
    environment: "your-environment",
    branch: "main",
  },
  // Where the "Edit in Contentstack" UI opens (region must match your stack)
  clientUrlParams: {
    protocol: "https",
    host: "app.contentstack.com",
    port: 443,
  },
});
```

### Key Configuration Options

Option

Description

ssr

Set to false for CSR mode (default: true)

mode

"builder" for Visual Builder, "preview" for Live Preview

stackSdk

Stack class from Contentstack.Stack(). Required for CSR to inject hash and content type UID

editButton.enable

Show/hide the edit button

For the full list of configuration options, see the [official SDK documentation](https://www.contentstack.com/docs/developers/set-up-live-preview/live-preview-implementation-for-nextjs-csr-app-router).

### Region-Specific Configuration

The clientUrlParams.host must match your Contentstack region. The default is app.contentstack.com (North America). For other regions, see the [region-specific endpoints](https://www.contentstack.com/docs/developers/contentstack-regions/api-endpoints).

```typescript
// North America (default)
clientUrlParams: {
  host: "app.contentstack.com";
}
```

### Initialization in React

```typescript
import { useEffect } from "react";
import ContentstackLivePreview from "@contentstack/live-preview-utils";

function App() {
  useEffect(() => {
    // Runs once on mount; init is global  - do not call per component unless you guard it
    ContentstackLivePreview.init({
      enable: true,
      ssr: false,
      stackDetails: {
        apiKey: process.env.NEXT_PUBLIC_CONTENTSTACK_API_KEY,
        environment: process.env.NEXT_PUBLIC_CONTENTSTACK_ENVIRONMENT,
      },
    });
  }, []);

  return <YourAppContent />;
}
```

### Initialization in Vue

```typescript
import { createApp } from "vue";
import ContentstackLivePreview from "@contentstack/live-preview-utils";

// Init before mount so the first fetch already sees preview session state
ContentstackLivePreview.init({
  enable: true,
  ssr: false,
  stackDetails: {
    apiKey: import.meta.env.VITE_CONTENTSTACK_API_KEY,
    environment: import.meta.env.VITE_CONTENTSTACK_ENVIRONMENT,
  },
});

createApp(App).mount("#app");
```

## Subscribing to Changes

### onEntryChange()

The primary method for CSR. Fires when content is edited, saved, or published. The callback receives no content - you refetch.

```typescript
import { onEntryChange } from "@contentstack/live-preview-utils";

// Callback gets no payload  - always refetch authoritative content yourself
onEntryChange(() => {
  fetchContent();
});

// Skip the immediate invocation right after subscribe (useful if you already rendered once)
onEntryChange(fetchContent, { skipInitialRender: true });
```

### onLiveEdit()

Fires during real-time editing (as the user types). Use for immediate feedback without waiting for auto-save:

```typescript
import { onLiveEdit } from "@contentstack/live-preview-utils";

useEffect(() => {
  // Higher frequency than onEntryChange; use only when you need keystroke-level 
updatesonLiveEdit(() => {
    fetchContent();
  });
}, []);
```

For most applications, onEntryChange is sufficient.

## Fetching With Preview Context

If you're using the Contentstack Delivery SDK configured with live_preview, it handles preview context automatically. For raw API calls, include the hash and preview token:

```typescript
// Delivery SDK attaches hash + preview token when running inside a preview iframe
const data = await stack.contentType("page").entry("entry-uid").fetch();

// Raw fetch: no magic  - forward hash and preview_token only when hash is present
const hash = ContentstackLivePreview.hash;
const headers = hash
  ? {
      preview_token: previewToken,
      live_preview: hash,
    }
  : {};
```

## Accessing Preview Context

```javascript
import ContentstackLivePreview from "@contentstack/live-preview-utils";

// Session hash from the parent frame; empty outside preview
const hash = ContentstackLivePreview.hash;
const config = ContentstackLivePreview.config;
// Useful fields: config.ssr, config.enable, config.stackDetails, config.windowType
```

windowType values: "independent" (direct browser), "builder" (Visual Builder iframe), "preview" (Live Preview / Timeline iframe).

## Updating State

Replace state atomically rather than merging:

```typescript
// Good: Replace state entirely
const fetchContent = async () => {
  const newData = await getContent();
  setContent(newData);
};

// Avoid: Partial merges can cause stale data
const fetchContent = async () => {
  const newData = await getContent();
  setContent((prev) => ({ ...prev, ...newData }));
};
```

## Complete React Example

```typescript
// lib/contentstack.ts
import ContentstackLivePreview from "@contentstack/live-preview-utils";
import contentstack from "@contentstack/delivery-sdk";

const stack = contentstack.stack({
  apiKey: process.env.REACT_APP_API_KEY,
  deliveryToken: process.env.REACT_APP_DELIVERY_TOKEN,
  environment: process.env.REACT_APP_ENVIRONMENT,
  live_preview: {
    preview_token: process.env.REACT_APP_PREVIEW_TOKEN,
    enable: true,
    host: "rest-preview.contentstack.com", // Preview API when session hash is active
  },
});

ContentstackLivePreview.init({
  enable: true,
  ssr: false,
  stackSdk: stack, // Required for CSR so hash and stack metadata stay in syncstackDetails: {
    apiKey: process.env.REACT_APP_API_KEY,
    environment: process.env.REACT_APP_ENVIRONMENT,
  },
});

export { stack, ContentstackLivePreview };
export const onEntryChange = ContentstackLivePreview.onEntryChange;

// pages/Home.jsximport React, { useState, useEffect } from "react";
import { stack, onEntryChange } from "../lib/contentstack";

function Home() {
  const [pageContent, setPageContent] = useState(null);
  const [loading, setLoading] = useState(true);

  const fetchPageContent = async () => {
    try {
      const result = await stack
        .contentType("page")
        .entry("home-page-entry-uid")
        .fetch(); // Uses preview or delivery automatically from session
      setPageContent(result);
    } catch (error) {
      console.error("Failed to fetch content:", error);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    onEntryChange(fetchPageContent); // Refetch on every preview-side content change
  }, []);

  if (loading) return <div>Loading...</div>;
  if (!pageContent) return <div>Content not found</div>;

  return (
    <main>
      <h1>{pageContent.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: pageContent.body }} />
    </main>
  );
}
```

## Multi-Entry Pages

If a page renders content from multiple entries, refetch all of them on any change:

```typescript
function PageWithMultipleEntries() {
  const [data, setData] = useState({
    header: null,
    content: null,
    footer: null,
  });

  const fetchAllContent = async () => {
    // One edit anywhere in preview can invalidate the whole page  - refresh everything together
    const [header, content, footer] = await Promise.all([
      stack.contentType("header").entry("header-uid").fetch(),
      stack.contentType("page").entry("page-uid").fetch(),
      stack.contentType("footer").entry("footer-uid").fetch(),
    ]);
    setData({ header, content, footer });
  };

  useEffect(() => {
    onEntryChange(fetchAllContent);
  }, []);

  return (
    <>
      <Header data={data.header} />
      <Content data={data.content} />
      <Footer data={data.footer} />
    </>
  );
}
```

## Cleanup

Treat subscriptions as part of component lifecycle:

```typescript
useEffect(() => {
  let mounted = true;

  const fetchContent = async () => {
    if (!mounted) return;
    const data = await stack.contentType("page").entry("uid").fetch();
    if (mounted) setContent(data); // Avoid setState after unmount if refetch is slow
  };

  onEntryChange(fetchContent);

  return () => {
    mounted = false;
  };
}, []);
```

## Common CSR Failures

### Mistake 1: Assuming the Event Contains Content

```typescript
// Wrong: event doesn't contain content
onEntryChange((eventData) => {
  setContent(eventData.content);
});

// Correct: refetch on event
onEntryChange(() => {
  fetchContent();
});
```

### Mistake 2: Refetching Without Preview Context

```typescript
// Wrong: CDN delivery only  - ignores draft session and live_preview hash
const data = await fetch("https://cdn.contentstack.io/...");

// Correct: preview host + token + hash for the active iframe session
const hash = ContentstackLivePreview.hash;
const data = await fetch(`https://rest-preview.contentstack.com/...`, {
  headers: { preview_token: previewToken, live_preview: hash },
});
```

### Mistake 3: Forgetting Cleanup

```typescript
useEffect(() => {
  const unsubscribe = onEntryChange(fetchContent);
  return () => unsubscribe?.(); // Drop listener when the component unmounts
}, []);
```

### Mistake 4: Multiple Refetches Per Change

```typescript
// Problematic: each component subscribes independently → one edit triggers three refetches

// Better: one onEntryChange at the page root, then pass props down
function Page() {
  const fetchAllPageData = async () => {
    const [header, content, footer] = await Promise.all([
      fetchHeader(),
      fetchContent(),
      fetchFooter(),
    ]);
    setPageData({ header, content, footer });
  };

  useEffect(() => {
    onEntryChange(fetchAllPageData);
  }, []);
}
```

Manual implementation without the SDK is possible but fragile and not recommended. The SDK handles postMessage handshakes, hash rotation, navigation, and cleanup that manual code invariably misses.

## Best Practices

- Initialize once, early - before any content fetching


- Use ssr: false - critical for CSR mode


- Single subscription per page - avoid multiple components subscribing independently


- Refetch atomically - replace state entirely, don't merge


- Handle loading states - show indicators while refetching


- Clean up on unmount - prevent memory leaks and stale updates


- Prefer the SDK - manual implementations are fragile



## Key Takeaways

- CSR subscribes to change events and refetches in the same browser runtime. No page reloads.


- Initialize once, early, with ssr: false. Late init means the first fetch misses preview context.


- One onEntryChange per page, not per component. One edit = one refetch.


- Replace state atomically. Merging risks mixing stale and fresh content.


- Clean up subscriptions on unmount.



## Check Your Understanding

- Why should you replace state entirely on each refetch rather than merging the new data into existing state? What specific failure does merging cause?


- What happens if three components on the same page each register their own onEntryChange callback? How would you restructure this?


- Your onEntryChange callback references a variable from the component's closure. The variable was correct when the callback was registered but is stale now. What pattern prevents this?






---

## Server-Side Rendering

Prerequisites: This chapter builds on [How Live Preview Works](https://developers-contentstack-com-production.contentstackapps.com/guides/the-ultimate-guide-to-contentstack-visual-building/how-live-preview-works). You should understand the session lifecycle, the hash, and the Preview API before proceeding. Familiarity with the [CSR chapter](https://developers-contentstack-com-production.contentstackapps.com/guides/the-ultimate-guide-to-contentstack-visual-building/client-side-rendering) helps for contrast but isn't required.

What you'll be able to do after this chapter:

- Implement request-scoped preview clients that prevent hash leakage between users


- Propagate the live preview hash across page navigation and redirects


- Explain why SSR preview reloads the iframe instead of refetching in place


- Disable caching correctly for preview responses at every layer



## Why this matters

The most dangerous SSR failure - one editor's preview hash leaking into another's session via a shared SDK instance - produces correct-looking results in development and breaks silently under production load.

In SSR, the server renders a complete page, sends it to the browser, and the server-side context is destroyed. Every preview update means a full round trip - there's no persistent process to receive events and refetch in place.

This contract holds regardless of your framework: Next.js, Nuxt, Remix, Astro SSR, Express, or custom Node.js.

## The SSR Live Preview Contract

Every SSR implementation must satisfy these requirements:

- Reload on change: The browser reloads when content changes (no in-place updates)


- Hash on every request: The server receives the preview hash with each request


- Per-request detection: Preview vs production is determined per request, not globally


- Draft data fetching: Use Preview API when hash is present


- No caching: Preview responses must never be cached



## The Update Cycle

flowchart TB

  editor["Editor changes content"]

  sdk["SDK signals reload"]

  iframe["Iframe reloads"]

  request["Server request"]

  fetch["Fetch draft data"]

  render["Render HTML"]


  editor --> sdk --> iframe --> request --> fetch --> render

## First Request Correctness

The initial request already contains the hash. Your server must detect this immediately and fetch draft content.

```typescript
// WRONG: Ignores live_preview on first paint → published HTML, then draft = flicker/wrong state
export async function getServerSideProps() {
  const data = await fetchDeliveryAPI();
  return { props: { data } };
}

// CORRECT: Same request must use Preview API whenever the hash is present
export async function getServerSideProps({ query }) {
  const isPreview = !!query.live_preview;
  const data = isPreview
    ? await fetchPreviewAPI(query.live_preview)
    : await fetchDeliveryAPI();
  return { props: { data } };
}
```

## Request-Scoped Clients

This is where many SSR implementations fail. Preview configuration must be request-scoped, not application-scoped.

### The Wrong Way

```typescript
// DON'T: One stack for all users  - concurrent requests overwrite each other's live_preview hashconst stack = contentstack.stack({
  /* ... */
});

app.get("/*", async (req, res) => {
  stack.livePreviewQuery(req.query);
  const data = await stack.contentType("page").entry().find();
  res.render("page", { data });
});
```

Under load, requests interleave. One request's preview hash contaminates another. Editors see each other's drafts, or production users see draft content.

### The Right Way

```typescript
// DO: Isolate preview config to this request only
app.get("/*", async (req, res) => {
  const stack = createContentstackClient(req.query.live_preview);
  const data = await stack.contentType("page").entry().find();
  res.render("page", { data });
});

function createContentstackClient(livePreviewHash) {
  const config = {
    apiKey: process.env.API_KEY,
    deliveryToken: process.env.DELIVERY_TOKEN,
    environment: process.env.ENVIRONMENT,
  };

  if (livePreviewHash) {
    config.live_preview = {
      enable: true,
      preview_token: process.env.PREVIEW_TOKEN,
      host: "rest-preview.contentstack.com",
    };
  }

  const stack = contentstack.stack(config);

  // Binds this request's hash to SDK queries (must pair with live_preview config above)
if (livePreviewHash) {
    stack.livePreviewQuery({ live_preview: livePreviewHash });
  }

  return stack;
}
```

## Disable All Caching for Preview

```typescript
// Preview responses are user- and session-specific; never cache at CDN or browser
if (isPreviewRequest(req)) {
  res.set("Cache-Control", "no-store, no-cache, must-revalidate");
  res.set("Pragma", "no-cache");
  res.set("Expires", "0");
}
```

Bypass CDN, application, and framework caches when the hash is present.

## Client-Side SDK Initialization

Even in SSR, the Live Preview SDK runs in the browser. It handles postMessage communication and tells the CMS to reload the iframe on changes.

```typescript
import ContentstackLivePreview from "@contentstack/live-preview-utils";

ContentstackLivePreview.init({
  enable: true,
  ssr: true, // Ask the parent frame to reload the iframe so the server runs again with a fresh hash
  stackDetails: {
    apiKey: "your-api-key",
    environment: "your-environment",
  },
});
```

## Hash Propagation in Navigation

The most common SSR preview bug: the hash gets dropped during navigation.

The editor opens a page, sees draft content, clicks a link, and suddenly sees published content. The link didn't include preview parameters.

```typescript
function navigateTo(url) {
  const currentUrl = new URL(window.location.href);
  const newUrl = new URL(url, window.location.origin);

  const previewHash = currentUrl.searchParams.get("live_preview");
  if (previewHash) {
    newUrl.searchParams.set("live_preview", previewHash);
    // CMS often adds these; carry them so the next SSR request stays in the same preview context
    ["content_type_uid", "entry_uid", "locale"].forEach((param) => {
      const value = currentUrl.searchParams.get(param);
      if (value) newUrl.searchParams.set(param, value);
    });
  }

  window.location.href = newUrl.toString();
}
```

Also check middleware and redirects - they often strip query parameters.

## Framework Implementations

### Next.js (App Router)

```typescript
// app/layout.js
import LivePreviewInit from './LivePreviewInit';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <LivePreviewInit />
      </body>
    </html>
  );
}

// app/LivePreviewInit.js
'use client';
import { useEffect } from 'react';
import ContentstackLivePreview from '@contentstack/live-preview-utils';

export default function LivePreviewInit() {
  useEffect(() => {
    ContentstackLivePreview.init({
      enable: true,
      ssr: true, // Triggers full iframe reloads so App Router RSC/SSR runs again per edit
      stackDetails: {
        apiKey: process.env.NEXT_PUBLIC_API_KEY,
        environment: process.env.NEXT_PUBLIC_ENVIRONMENT
      }
    });
  }, []);
  return null;
}

// app/[slug]/page.js
import { createContentstackClient } from '@/lib/contentstack';

export default async function Page({ params, searchParams }) {
  // searchParams includes live_preview on each iframe reload from the CMSconst client = createContentstackClient(searchParams.live_preview);
  const data = await client.getEntry(params.slug);
  return <PageContent data={data} />;
}
```

### Next.js (Pages Router)

```typescript
// pages/[slug].js
export default function Page({ data }) {
  useEffect(() => {
    // Browser-side: coordinate reloads with the stack UI; server already fetched `data`
    ContentstackLivePreview.init({
      enable: true,
      ssr: true,
      stackDetails: {
        /* ... */
      },
    });
  }, []);
  return <PageContent data={data} />;
}

export async function getServerSideProps({ query }) {
  const client = createContentstackClient(query.live_preview);
  const data = await client.getEntry(query.slug);
  return { props: { data } };
}
```

### Nuxt.js

```typescript
// pages/[slug].vue
<script setup>
const route = useRoute();
const livePreviewHash = route.query.live_preview;

const { data } = await useFetch('/api/content', {
  // Forward hash to your server route so it can call Preview API for this session
  query: { slug: route.params.slug, live_preview: livePreviewHash }
});

onMounted(() => {
  import('@contentstack/live-preview-utils').then(({ default: LP }) => {
    LP.init({ enable: true, ssr: true, stackDetails: { /* ... */ } });
  });
});
</script>
```

### Express/Node.js

```typescript
app.get("/*", async (req, res) => {
  const livePreviewHash = req.query.live_preview;
  const client = createContentstackClient(livePreviewHash);

  const data = await client.getEntry(req.path);

  if (livePreviewHash) {
    res.set("Cache-Control", "no-store");
  } else {
    res.set("Cache-Control", "public, max-age=3600");
  }

  // Pass flag into template if you need to inject Live Preview script or edit tags only in preview
  const html = renderPage(data, livePreviewHash);
  res.send(html);
});
```

## Performance Expectations

SSR preview is inherently slower than CSR:

Operation

CSR Preview

SSR Preview

Change to visible

~100-300ms

~500-2000ms

Network round trips

1 (API fetch)

2 (page load + API)

Server CPU

None

Full render

Perceived feel

Instant

Noticeable pause

If preview speed is critical, consider using CSR mode for preview even if production uses SSR.

## Debugging SSR Preview

Check in order:

- Is the hash in the URL? Check the iframe src in browser devtools


- Is the SDK initializing? Check console for init logs


- Is ssr: true set? Check your init configuration


- Is the server seeing the hash? Add server logging


- Is the server using Preview API? Log API endpoints being called


- Is caching disabled? Check response headers



## Key Takeaways

- SSR preview reloads the iframe on every edit. No in-place refetching.


- Initialize the SDK client-side with ssr: true.


- Preview config must be request-scoped. Global SDK instances leak hashes between editors.


- The hash must survive navigation: links, redirects, and middleware must preserve it.


- Bypass all caching (CDN, application, framework) when the hash is present.



## Check Your Understanding

- Why can't you use a single global Contentstack SDK instance for SSR preview? What specifically goes wrong when two editors preview simultaneously?


- An editor opens a preview, sees draft content on the first page, clicks a link to another page, and sees published content. What broke?


- Your SSR preview works perfectly in development but sometimes shows the wrong editor's changes in production. What architectural difference between development and production would cause this?






---

## Static Site Generation

Prerequisites: This chapter builds on [How Live Preview Works ](/guides/the-ultimate-guide-to-contentstack-visual-building/how-live-preview-works)and shares concepts with [Server-Side Rendering](/guides/the-ultimate-guide-to-contentstack-visual-building/server-side-rendering). Understanding why preview requires runtime rendering helps you see why SSG needs an escape hatch.

What you'll be able to do after this chapter:

- Explain why SSG fundamentally conflicts with Live Preview and what the escape hatch is


- Configure Next.js Draft Mode or Astro hybrid rendering for preview


- Avoid the client-side patching antipattern and the hydration mismatches it causes



## Why this matters

Static files can't show drafts. Teams that try to patch static content client-side hit hydration mismatches and flicker. This chapter shows the framework-level escape hatches that make preview work cleanly.

SSG's core value (pre-built HTML served without runtime computation) directly conflicts with Live Preview's need for draft-aware, session-scoped content. Static files can't show drafts. The solution: framework-level preview modes that temporarily switch from static to dynamic rendering.

## The Fundamental Conflict

At build time, your generator fetches published content and renders HTML files. These files are deployed to a CDN. No server runs, no API calls fire per request. Live Preview needs runtime rendering of draft content - rebuilding and redeploying on every keystroke isn't practical.

## The Solution: Preview Mode

When preview mode is active, static files are bypassed and requests are handled dynamically (like SSR), fetching from the Preview API per request. When inactive, static files serve normally with no performance impact.

Conceptually, SSG preview is SSR with guardrails.

Aspect

Production (SSG)

Preview Mode

Rendering

Static files

Dynamic (SSR)

Content source

Built-in content

Preview API

Caching

Full CDN cache

No caching

Hash handling

Not applicable

Per-request


flowchart TB

  decision{"Preview mode active?"}

  production["Production (SSG)<br/>Build time fetch<br/>Static HTML<br/>CDN cached<br/>No draft content"]

  preview["Preview Mode<br/>Static files bypassed<br/>Dynamic render (SSR)<br/>Preview API + hash<br/>No caching"]


  decision -->|No| production

  decision -->|Yes| preview

## Framework Preview Modes

### Next.js (App Router with Draft Mode)

```typescript
// app/api/draft/route.js
import { draftMode } from 'next/headers';

export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const secret = searchParams.get('secret');
  const slug = searchParams.get('slug');

  if (secret !== process.env.PREVIEW_SECRET) {
    return new Response('Invalid token', { status: 401 });
  }

  // Cookie-backed flag: subsequent requests render dynamically and can use Preview API
  draftMode().enable();

  return new Response(null, {
    status: 307,
    headers: { Location: `/${slug}` }
  });
}

// app/[slug]/page.js
import { draftMode } from 'next/headers';

export default async function Page({ params }) {
  const { isEnabled } = draftMode();

  // Draft mode ON → behave like SSR for this request; OFF → use built static data path
  const data = isEnabled
    ? await fetchDraftContent(params.slug)
    : await fetchPublishedContent(params.slug);

  return <PageContent data={data} />;
}

export async function generateStaticParams() {
  const pages = await fetchAllPages();
  return pages.map(page => ({ slug: page.slug }));
}
```

### Next.js (Pages Router with Preview Mode)

```typescript
// pages/api/preview.js
export default function handler(req, res) {
  const { secret, slug } = req.query;

  if (secret !== process.env.PREVIEW_SECRET) {
    return res.status(401).json({ message: 'Invalid token' });
  }

  res.setPreviewData({}); // Sets preview cookies for the browser session
  res.redirect(`/${slug}`);
}

// pages/[slug].js
export async function getStaticProps({ params, preview }) {
  const data = preview
    ? await fetchDraftContent(params.slug)
    : await fetchPublishedContent(params.slug);

  return {
    props: { data },
    // Disable ISR caching while previewing so drafts are not frozen in edge cache
    revalidate: preview ? false : 60
  };
}

export async function getStaticPaths() {
  const pages = await fetchAllPages();
  return {
    paths: pages.map(page => ({ params: { slug: page.slug } })),
    fallback: 'blocking'
  };
}
```

### Astro (Hybrid Rendering)

Astro supports per-route SSR, making it straightforward:

```typescript
// astro.config.mjs
export default defineConfig({
  output: 'hybrid', // Allow some routes to opt into server rendering
});

// src/pages/[slug].astro
export const prerender = false; // This route is always server-rendered (needed for live_preview hash)

const { slug } = Astro.params;
const livePreviewHash = Astro.url.searchParams.get('live_preview');

const data = livePreviewHash
  ? await fetchDraftContent(slug, livePreviewHash)
  : await fetchPublishedContent(slug);
```

## The Common Mistake: Client-Side Patching

Don't try to "patch" static content with client-side refetching:

```typescript
// DON'T DO THIS
export async function getStaticProps({ params }) {
  const data = await fetchPublishedContent(params.slug);
  return { props: { data } };
}

function Page({ data }) {
  const [content, setContent] = useState(data);

  useEffect(() => {
    if (isPreviewMode()) {
      // Client replaces props after paint → HTML from build ≠ first client render
      fetchDraftContent().then(setContent);
    }
  }, []);

  return <Content data={content} />;
}
```

This fails because:

- Hydration mismatch: Server HTML has published content; client renders draft content


- Layout flicker: Published content flashes before draft content appears


- Inconsistent state: Some content updates, some doesn't



If you use SSG, accept that preview means temporarily leaving SSG. Don't patch static content dynamically.

## Integrating with Contentstack Live Preview

- Create a preview API route that enables preview mode and redirects


- Configure your stack's Live Preview Base URL to point to your preview endpoint


- Initialize the SDK client-side with ssr: true


- In preview mode, fetch from Preview API with the hash



## A Note on Contentstack's Official SSG Guidance

Contentstack's documentation states that SSG sites run Live Preview in CSR mode (ssr: false), fetching content dynamically in the browser rather than triggering iframe reloads.

The full picture: your framework's preview mode bypasses static files and renders dynamically. The Live Preview SDK runs in CSR mode within that dynamic context, fetching draft content and re-rendering in place. The framework handles "escape from static" and the SDK handles the "fetch draft content" loop.

If your SSG framework lacks a preview mode, you can run the SDK in CSR mode and fetch draft content client-side on top of the static page. This works but causes the hydration mismatch and flicker described above - use it as a fallback, not a primary strategy.

## Key Takeaways

- Static files can't show drafts. Use your framework's preview mode to temporarily switch to dynamic rendering.


- In preview mode, SSG pages behave like SSR: Preview API per request, no caching.


- Don't patch static content client-side. It causes hydration mismatches and flicker.


- The SDK runs in CSR mode (ssr: false) within the dynamic preview context.



## Check Your Understanding

- Why does client-side patching of static content cause a hydration mismatch? What specifically is different between the server-rendered HTML and the first client render?


- Your team uses Next.js with ISR (Incremental Static Regeneration). A developer proposes setting revalidate: 1 during preview to get near-real-time updates. Why is this insufficient for Live Preview?


- A colleague argues that since preview mode makes SSG pages behave like SSR, there's no performance difference. What's wrong with this reasoning in production?






---

## Middleware and Database-Backed Architectures

Prerequisites: This chapter builds on How Live Preview Works and assumes familiarity with at least one rendering strategy chapter (CSR, SSR, or SSG). The core concepts - hash propagation, Preview vs Delivery API switching, and caching rules - apply here across additional architectural layers.

What you'll be able to do after this chapter:

Route preview context through a BFF or API proxy without exposing tokens to the browser

Ensure edge middleware, redirects, and URL normalization preserve the live preview hash

Bypass database and memory caches for preview requests while keeping production caching intact

## Why this matters

Each layer between the browser and Contentstack (API proxies, BFFs, edge middleware, database caches) can silently drop the preview hash. When that happens, preview falls back to published content with no error.

Many production systems don't fetch content directly from Contentstack in the frontend. They use intermediary layers: BFF services, edge middleware, or database-backed caching. Live Preview works with all of these, but only if preview context flows end to end.

## The Core Rule

Preview context must travel with the request. The live preview hash needs to reach whatever layer actually fetches content from Contentstack. If the frontend assumes preview state while the backend fetches from delivery services, the page renders a mix of draft and published content.

flowchart LR

  frontend["Frontend<br/>Hash in URL"]

  middleware["BFF / Middleware<br/>Preserve hash"]

  contentstack["Contentstack<br/>Preview or delivery"]

  rule1["Rule: Decide preview vs delivery where data is fetched."]

  rule2["If hash is present, bypass caches and databases."]


  frontend --> middleware --> contentstack

  middleware -.-> rule1 -.-> rule2

## API Route as Content Proxy

Instead of calling Contentstack directly from the browser or server components, route all content requests through your own API endpoint. This keeps tokens server-side, centralizes endpoint switching, and lets the same fetch function work for both preview and production.

The [Next.js Middleware Kickstart](https://github.com/contentstack/kickstart-next-middleware) implements this pattern. Here's how it works.

### The API Route

A single API route handles all content requests. It reads query parameters to determine whether to call the Preview API or Delivery API:

```typescript
// app/api/middleware/route.ts
import contentstack from "@contentstack/delivery-sdk";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const content_type_uid = searchParams.get("content_type_uid");
  const pageUrl = searchParams.get("url");
  const live_preview = searchParams.get("live_preview");
  // Optional; stack may send it for time-aligned preview  - forward when present
  const preview_timestamp = searchParams.get("preview_timestamp");

  // Pick the right API based on preview context
  const hostname = live_preview
    ? process.env.CONTENTSTACK_PREVIEW_HOST    // rest-preview.contentstack.com
    : process.env.CONTENTSTACK_DELIVERY_HOST;  // cdn.contentstack.io

  const headers = new Headers({
    "Content-Type": "application/json",
    "api_key": process.env.CONTENTSTACK_API_KEY,
  });

  if (live_preview) {
    headers.set("live_preview", live_preview);
    headers.set("preview_token", process.env.CONTENTSTACK_PREVIEW_TOKEN);
    if (preview_timestamp) headers.set("preview_timestamp", preview_timestamp);
  } else {
    headers.set("access_token", process.env.CONTENTSTACK_DELIVERY_TOKEN);
  }

  const query = encodeURIComponent(JSON.stringify({ url: pageUrl }));
  // v3 entries API: same path shape for preview and delivery; host + headers decide which service answers
  const apiUrl = `https://${hostname}/v3/content_types/${content_type_uid}/entries?environment=${process.env.CONTENTSTACK_ENVIRONMENT}&query=${query}`;

  const response = await fetch(apiUrl, { headers });
  const data = await response.json();
  const entry = data.entries?.[0];

  if (!entry) return Response.json({ error: "Not found" }, { status: 404 });

  // Add edit tags for Visual Builder when in previewif (live_preview) {
    contentstack.Utils.addEditableTags(entry, content_type_uid, true);
  }

  return Response.json(entry);
}
```

### The Fetch Function

Content fetching reads the hash from the SDK and passes it to the API route as a query parameter. The same function works whether called from a server component or a client component:

```typescript
// lib/contentstack.ts
import ContentstackLivePreview from "@contentstack/live-preview-utils";

export async function getPage(baseUrl: string, url: string) {
  // Populated inside the preview iframe after the SDK syncs with the parent frame
  const livePreviewHash = ContentstackLivePreview.hash;
  const apiUrl = new URL("/api/middleware", baseUrl);

  apiUrl.searchParams.set("content_type_uid", "page");
  apiUrl.searchParams.set("url", url);

  if (livePreviewHash) {
    apiUrl.searchParams.set("live_preview", livePreviewHash);
  }

  const result = await fetch(apiUrl);
  return result.json();
}
```

### Dynamic Rendering

The page component decides how to render based on whether preview is active. Preview uses CSR (client-side refetch loop), production uses SSR (server-rendered):

```typescript
// app/page.tsx
export default async function Home() {
  // Preview: client subtree owns SDK + refetch; production: server calls proxy without hash
  if (isPreview) return <Preview path="/" baseUrl={baseUrl} />;

  const page = await getPage(baseUrl, "/");
  return <Page page={page} />;
}
```

The <Preview> component initializes the Live Preview SDK with ssr: false, subscribes to onEntryChange, and refetches through the same /api/middleware route on every edit. The <Page> component is a static renderer - no SDK, no subscriptions.

This works because the API route doesn't care who's calling it. The preview component calls it with a hash and gets draft content. The server component calls it without a hash and gets published content. Same route, same logic, different results.

### Why This Pattern

- Tokens never reach the browser - preview tokens and delivery tokens stay in server-side environment variables


- Single place for endpoint switching - one if (live_preview) check instead of scattered logic


- Works with any rendering strategy - CSR, SSR, or both through the same API route


- Easy to extend - add logging, rate limiting, or response transformation in one place



## Backend-for-Frontend (BFF) Architecture

The proxy pattern above generalizes to any BFF architecture. The frontend calls your BFF, the BFF fetches from Contentstack, and the BFF returns processed content. The BFF must know about preview context:

Frontend forwards preview parameters:

```typescript
async function fetchPage(slug) {
  const params = new URLSearchParams(window.location.search);
  const livePreviewHash = params.get('live_preview');

  const url = new URL(`/api/page/${slug}`, window.location.origin);
  if (livePreviewHash) {
    url.searchParams.set('live_preview', livePreviewHash); // BFF must echo this to Contentstack
  }

  const response = await fetch(url);
  return response.json();
}
```

BFF detects preview and switches API:

```typescript
app.get('/api/page/:slug', async (req, res) => {
  const livePreviewHash = req.query.live_preview;
  const client = createContentstackClient(livePreviewHash); // Must be request-scoped (see Chapter 3 for why)
  const data = await client.getPageBySlug(req.params.slug);

  if (livePreviewHash) {
    res.set('Cache-Control', 'no-store');
  } else {
    res.set('Cache-Control', 'public, max-age=300');
  }

  res.json(data);
});
```

## Edge Middleware

Edge middleware (Vercel Edge, Cloudflare Workers, Lambda@Edge) runs between the CDN and your origin. Three common pitfalls:

### Pitfall 1: Stripping Query Parameters

```typescript
// DANGEROUS: This breaks Live Preview
export function middleware(request) {
  const url = new URL(request.url);
  url.search = '';  // Removes live_preview!return NextResponse.rewrite(url);
}

// FIX: Preserve preview parameters
export function middleware(request) {
  const url = new URL(request.url);
  const previewParams = ['live_preview', 'content_type_uid', 'entry_uid', 'locale'];
  const preserved = new URLSearchParams();

  previewParams.forEach(param => {
    const value = url.searchParams.get(param);
    if (value) preserved.set(param, value);
  });

  url.search = preserved.toString();
  return NextResponse.rewrite(url);
}
```

### Pitfall 2: URL Normalization

```typescript
// DANGEROUS: Redirect may lose query params
export function middleware(request) {
  const url = new URL(request.url);
  if (!url.pathname.endsWith('/')) {
    url.pathname += '/';
    return NextResponse.redirect(url); // Relative redirects can drop search string depending on runtime
  }
}

// FIX: Use URL constructor (preserves search params by default)
export function middleware(request) {
  const url = new URL(request.url);
  if (!url.pathname.endsWith('/')) {
    const newUrl = new URL(url);
    newUrl.pathname += '/';
    return NextResponse.redirect(newUrl);
  }
}
```

### Pitfall 3: Authentication Redirects

```typescript
// DANGEROUS: Redirect loses preview context
export function middleware(request) {
  if (!isAuthenticated(request)) {
    return NextResponse.redirect('/login');  // Preview params lost!
  }
}

// FIX: Forward preview params
export function middleware(request) {
  if (!isAuthenticated(request)) {
    const loginUrl = new URL('/login', request.url);
    const previewHash = request.nextUrl.searchParams.get('live_preview');
    if (previewHash) {
      loginUrl.searchParams.set('return_preview', previewHash);
    }
    return NextResponse.redirect(loginUrl);
  }
}
```

## Database-Backed Content Caching

Some architectures sync Contentstack content to a local database for performance. Preview content must never be written to the database:

```typescript
async function getContent(slug, livePreviewHash) {
  if (livePreviewHash) {
    // ALWAYS fetch directly from Contentstack for preview
    // Never read from or write to database
    return fetchFromContentstackPreview(slug, livePreviewHash);
  }

  // Production: Read from database
const cached = await database.findBySlug(slug);
  if (cached) return cached;

  const fresh = await fetchFromContentstackDelivery(slug);
  await database.upsert(slug, fresh);
  return fresh;
}
```

Preview content is session-scoped. Writing it to shared storage means other users might see drafts, content might outlive the editing session, and different editors' drafts might overwrite each other.

### Bypassing the Cache Layer

```typescript
class ContentService {
  async getPage(slug, options = {}) {
    const { livePreviewHash } = options;

    if (livePreviewHash) {
      // Bypass all caches for preview
      return this.fetchFromPreviewAPI(slug, livePreviewHash);
    }

    return this.fetchWithCaching(slug);
  }

  async fetchFromPreviewAPI(slug, hash) {
    const response = await fetch(
      `https://rest-preview.contentstack.com/...`,
      {
        headers: {
          'preview_token': process.env.PREVIEW_TOKEN,
          'live_preview': hash
        }
      }
    );
    return response.json();
    // No caching, no database write
  }

  async fetchWithCaching(slug) {
    const memCached = this.memoryCache.get(slug);
    if (memCached) return memCached;

    const dbCached = await this.database.findBySlug(slug);
    if (dbCached) {
      this.memoryCache.set(slug, dbCached);
      return dbCached;
    }

    const fresh = await this.fetchFromDeliveryAPI(slug);
    await this.database.upsert(slug, fresh);
    this.memoryCache.set(slug, fresh);
    return fresh;
  }
}
```

## Server-Side Data Aggregation

When pages combine content from multiple sources, only Contentstack content uses the preview hash:

```typescript
async function getPageData(slug, livePreviewHash) {
  const [content, products, user] = await Promise.all([
    livePreviewHash
      ? fetchContentstackPreview(slug, livePreviewHash)
      : fetchContentstackDelivery(slug),

    fetchProducts(), // Commerce/CMS-agnostic sources: keep on delivery paths
    fetchCurrentUser()
  ]);

  return { content, products, user, isPreview: !!livePreviewHash };
}
```

## Debugging Context Propagation

When Live Preview isn't working in a complex architecture, trace the hash through each layer:

- Is the hash in the initial URL? Check browser devtools


- Does the frontend forward the hash to the BFF? Log BFF requests


- Does the BFF use preview API? Log API calls


- Does middleware preserve the hash? Log before/after middleware


- Is the database being bypassed? Check data source for preview requests



## Key Takeaways

- Preview context must travel end to end. Every layer must forward the hash.


- A BFF/API proxy centralizes token management and endpoint switching in one place.


- Edge middleware is a common source of silent hash loss (URL normalization, parameter stripping, auth redirects).


- Never write preview content to a shared database or persistent cache.



## Check Your Understanding

- Your BFF returns cached content for performance. An editor activates Live Preview, but their changes don't appear. What's the most likely cause, and how would you fix the BFF to handle both preview and production correctly?


- Why should preview content never be written to a shared database, even temporarily? What would happen if two editors previewed the same page simultaneously and your database stored both drafts?


- Your edge middleware normalizes URLs by adding a trailing slash with a redirect. Why might this break Live Preview, and how would you fix it?



## Exercise: Trace the Hash

Map the path the live preview hash takes through your own architecture, from the CMS iframe URL to the final Contentstack API call. For each layer, answer:

- Does this layer receive the hash? How? (query parameter, header, cookie)


- Does this layer forward the hash? To where?


- Could this layer silently drop the hash? (redirects, URL rewrites, parameter stripping)


- Does this layer cache responses? Is caching bypassed when the hash is present?



If you find a gap - a layer that doesn't forward the hash or doesn't bypass caching - that's where your preview will break.




---

## Edit Tags and Visual Builder

Prerequisites: This chapter builds on How Live Preview Works. You should have a working Live Preview implementation (via any rendering strategy) before adding edit tags. Edit tags add interactivity on top of Live Preview's real-time updates.

What you'll be able to do after this chapter:

- Add data-cslp edit tags to your components so editors can click any element to jump to its CMS field


- Construct correct field paths for flat fields, nested modular blocks, repeated items, and referenced entries


- Enable Visual Builder mode and understand how it scans the DOM to create an interactive editing surface


- Make informed decisions about which elements to tag and which to skip



## Why this matters

Edit tags let editors click on any rendered element to jump directly to its CMS field. Visual Builder takes this further, adding controls for reordering, adding, and deleting blocks directly in the preview.

Live Preview updates content in real time. Edit tags add the next layer: they let editors click directly on rendered elements to jump to the corresponding CMS field. Visual Builder uses these tags to transform your preview into an interactive editing surface.

## What Edit Tags Do

From the CMS's perspective, your rendered page is just HTML - it has no idea which entry produced the content or which fields map to which elements. Edit tags supply the missing context:

```xml
<!-- Without edit tags: generic HTML -->
<h1>Welcome to Our Company</h1><p>We help businesses grow through innovative solutions.</p>

<!-- With edit tags: CMS knows which field each element maps to -->
<h1 data-cslp="page.blt123.en-us.title">Welcome to Our Company</h1><p data-cslp="page.blt123.en-us.description">We help businesses grow...</p>
```

Edit tags are metadata for editing, not content. Your site renders correctly without them - they add interactivity on top of Live Preview's real-time updates.

## Anatomy of an Edit Tag

Edit tags use the data-cslp attribute with a structured value:

```
data-cslp="{content_type_uid}.{entry_uid}.{locale}.{field_path}"
```

Component

Description

Example

content_type_uid

Content type identifier

page, blog_post

entry_uid

Entry identifier

blt80654132ff521260

locale

Language/locale code

en-us, de-de

field_path

Path to the specific field

title, body.0.text

For a simple field:

`data-cslp="page.blt123.en-us.title"`For a nested field in a modular block:

```
data-cslp="page.blt123.en-us.page_components.0.hero.headline"
```

The field path must exactly match the path in your content model.

## Using addEditableTags

addEditableTags from @contentstack/utils is the recommended way to add edit tags to your entries. It walks the entry's field structure, builds the correct data-cslp paths for every field (including nested modular blocks and repeated items), and attaches them to a $ property on the entry object. You don't need to construct paths manually - the utility handles content type UID, entry UID, locale, and field hierarchy for you.

This is the approach used in the kickstart projects and should be the default in any Live Preview implementation. Call it once in your data layer, immediately after fetching, and every component downstream gets correct edit tags for free.

### The Data Layer Pattern

Centralize addEditableTags in the function that fetches content. This way every component that receives the entry already has $ populated:

```javascript
import contentstack from "@contentstack/delivery-sdk";
import { addEditableTags } from "@contentstack/utils";

export async function getPage(url) {
  const result = await stack
    .contentType("page")
    .entry()
    .query()
    .where("url", QueryOperation.EQUALS, url)
    .find();

  const entry = result.entries?.[0];

  if (entry) {
    // One call generates edit tags for every field in the entry
    addEditableTags(entry, "page", true, "en-us");

    // Also tag any referenced entries with their own content type
    if (entry.author) {
      addEditableTags(entry.author, "author", true, "en-us");
    }
  }

  return entry;
}

// entry.$.title → { 'data-cslp': 'page.blt123.en-us.title' }
// entry.$.page_components__0 → { 'data-cslp': 'page.blt123.en-us.page_components__0' }
// entry.author.$.name → { 'data-cslp': 'author.blt456.en-us.name' }
```

Parameter

Type

Description

entry

object

The entry data from Contentstack

content_type_uid

string

The content type identifier

tagsAsObject

boolean

true returns { 'data-cslp': '...' }, false returns a string

locale

string

The locale code

### Using $ in Components

Once addEditableTags has been called in the data layer, components simply spread from entry.$:

```
// React (tagsAsObject: true) - just spread the $ property
<h1 {...entry.$?.title}>{entry.title}</h1>
<p {...entry.$?.description}>{entry.description}</p>
```

```
<!-- EJS/Handlebars (tagsAsObject: false) -->
<h1 {{ entry.$.title }}>{{ entry.title }}</h1>
```

Every example in this chapter assumes addEditableTags was already called in the data layer. Components never call it themselves.

## Field Paths in Practice

For flat content models, paths are straightforward: "title", "body". For complex nested structures, paths must navigate the hierarchy:

```javascript
// Content model with modular blocks
{
  page_components: [
    { hero: { headline: "Welcome", subheading: "Your journey starts here" } },
    { features: { items: [{ title: "Fast" }, { title: "Secure" }] } },
  ];
}

// Field paths:
// "page_components.0.hero.headline"
// "page_components.0.hero.subheading"
// "page_components.1.features.items.0.title"
// "page_components.1.features.items.1.title"
```

Accuracy matters more than coverage. An incorrect path opens the wrong field and confuses editors. This is why addEditableTags is so valuable - it generates correct paths for the entire entry structure, including modular blocks and nested arrays, so you don't have to build them by hand.

### Modular Blocks

Each block has a type, an index in the array, and type-specific fields. When you call addEditableTags on the entry, it generates $ properties for every level of nesting, including indexed keys like page_components__0 for each block:

```typescript
// addEditableTags was already called in the data layer (see "Using addEditableTags" above)
// entry.$ now contains paths for all blocks and their fields

function PageComponents({ entry }) {
  return entry.page_components?.map((block, index) => {
    // addEditableTags generates `page_components__{index}` keys on entry.$
    const blockTag = entry.$?.[`page_components__${index}`];

    switch (block._content_type_uid) {
      case "hero_block":
        return (
          <HeroBlock key={index} block={block.hero_block} tag={blockTag} />
        );
      case "features_block":
        return (
          <FeaturesBlock
            key={index}
            block={block.features_block}
            tag={blockTag}
          />
        );
      default:
        return null;
    }
  });
}

function HeroBlock({ block, tag }) {
  return (
    <section {...tag}>
      {/* block.$ contains edit tags for fields within this block */}
      <h1 {...block.$?.headline}>{block.headline}</h1>
      <p {...block.$?.description}>{block.description}</p>
    </section>
  );
}
```

flowchart LR

  subgraph model["Content Model"]

    cm1["page_components[0].hero.headline"]

    cm2["page_components[1].features.items[0].title"]

    cm3["page_components[1].features.items[1].title"]

  end


  subgraph tags["Edit Tags"]

    et1["page.{uid}.en-us.page_components.0.hero.headline"]

    et2["page.{uid}.en-us.page_components.1.features.items.0.title"]

    et3["page.{uid}.en-us.page_components.1.features.items.1.title"]

  end


  cm1 --> et1

  cm2 --> et2

  cm3 --> et3

### Repeated Items Within Blocks

When blocks contain arrays, addEditableTags generates $ properties on each array item too:

```typescript
function FeaturesBlock({ block, tag }) {
  return (
    <section {...tag}>
      <h2 {...block.$?.section_title}>{block.section_title}</h2>
      <ul>
        {block.items?.map((item, itemIndex) => (
          <li key={itemIndex}>
            {/* addEditableTags generates $ on each array item with the correct index */}
            <span {...item.$?.title}>{item.title}</span>
          </li>
        ))}
      </ul>
    </section>
  );
}
```

### Referenced Entries

Referenced entries have their own UIDs. The data layer pattern above already shows how to tag them separately with their own content type UID. In components, use $ the same way as any other entry:

```typescript
function AuthorCard({ author }) {
  // author.$ was generated by addEditableTags with content type "author" in the data layer
  // Clicking opens the author entry directly, not the referencing field on the page
  return (
    <div>
      <h3 {...author.$?.name}>{author.name}</h3>
      <p {...author.$?.bio}>{author.bio}</p>
    </div>
  );
}
```

## Centralized Tag Helpers

If you fetch multiple content types, a helper can standardize how addEditableTags is called in your data layer:

```typescript
import { addEditableTags } from "@contentstack/utils";

// Call in your data layer after fetching - not in components
export function tagEntry(entry, contentTypeUid, locale = "en-us") {
  if (!entry) return entry;
  addEditableTags(entry, contentTypeUid, true, locale);
  return entry;
}

// Usage in data layer functions:
export async function getPage(url) {
  const result = await stack
    .contentType("page")
    .entry()
    .query()
    .where("url", QueryOperation.EQUALS, url)
    .find();
  return tagEntry(result.entries?.[0], "page");
}

export async function getHeader() {
  const result = await stack.contentType("header").entry().query().find();
  return tagEntry(result.entries?.[0], "header");
}
```

Components then use entry.$?.fieldName as usual - they don't know or care about the tagging:

```typescript
function HeroComponent({ entry }) {
  return (
    <div>
      <h1 {...entry.$?.title}>{entry.title}</h1>
      <p {...entry.$?.subtitle}>{entry.subtitle}</p>
    </div>
  );
}
```

## Granularity Trade-offs

Tag these: Primary content (headlines, body text, images), repeated items (list items, cards), CTAs and links, editable metadata.

Skip these: Structural elements (containers, wrappers), derived content (computed values, formatted dates - tag the source field), decorative elements.

Over-tagging clutters Visual Builder with too many clickable regions. Under-tagging forces editors to hunt through the entry form. Tag the content editors frequently modify.

## Visual Builder

Visual Builder transforms Live Preview from a passive display into an interactive editing surface. It's built entirely on top of Live Preview and edit tags - no new data flow, just an interaction layer.

flowchart TB

  builder["Visual Builder Layer<br/>Scans DOM for edit tags, builds click targets"]

  preview["Live Preview Layer<br/>Session, events, preview content updates"]

  website["Your Website<br/>Renders content with data-cslp attributes"]


  builder --> preview --> website

### Enabling Visual Builder

Requires Live Preview Utils SDK v3.0+ and Delivery SDK v3.20.3+. Set mode to "builder":

```typescript
ContentstackLivePreview.init({
  mode: "builder", // Enables DOM scanning + click-to-field on top of Live Preview
  stackDetails: {
    apiKey: "your-stack-api-key",
    environment: "your-environment",
  },
  editInVisualBuilderButton: {
    enable: true,
    position: "bottom-right", // Floating control to jump back into Visual Builder
  },
});
```

### How It Works

- DOM scanning: Scans your page for elements with data-cslp attributes


- Region detection: Calculates position and bounds for each tagged element


- Field mapping: Parses the data-cslp value to extract content type, entry UID, locale, and field path


- Interaction binding: Attaches event handlers to the regions


- Continuous monitoring: Re-scans the DOM as the page updates



### What Makes It Work Well

- Stable DOM structure: If your DOM changes after scanning, the region map becomes stale


- Consistent element-to-field mapping: Each tag should consistently map to the same element


- Predictable rendering: Avoid rendering critical content only after client-side effects



### Common Failures

- Missing edit tags: Elements aren't clickable


- Incorrect edit tags: Clicking opens the wrong field


- Dynamic DOM changes: Elements work initially but stop after re-renders


- Stale regions: Visual Builder's map is out of sync after content updates



### Visual Builder and SSR

Server-rendered HTML must include data-cslp attributes. Client-side hydration must preserve them. After iframe reloads (SSR updates), Visual Builder re-scans automatically.

### Live Preview vs Visual Builder

Feature

Live Preview

Visual Builder

Real-time content updates

Yes

Yes

Click to navigate to field

No

Yes

Visual editing controls

No

Yes

Requires edit tags

No

Yes

DOM scanning

No

Yes

## Visual Builder-Specific Features

### Multiple Field Actions

To let editors add, delete, and reorder blocks through Visual Builder, attach the edit tag to the parent wrapper using the $ object:

```typescript
<div className="page-components">
  {entry.page_components?.map((component, index) => (
    // addEditableTags generates `page_components__{index}` keys on entry.$
    <div key={index} {...entry.$[`page_components__${index}`]}>
      <ComponentRenderer component={component} />
    </div>
  ))}
</div>
```

### Add Button Direction

Control the "Add" button placement with data-add-direction:

```xml
<div className="page-components" data-add-direction="vertical">
  {/* block components */}
</div>
```

Values: vertical, horizontal, none (hides the button).

### Empty Block Placeholders

When a Modular Blocks field is empty, show a clickable placeholder:

```javascript
import { VB_EmptyBlockParentClass } from "@contentstack/live-preview-utils";
```

```javascript
{
  /* VB_EmptyBlockParentClass: drop zone when modular blocks field is empty */
}
<div
  className={`page-components ${VB_EmptyBlockParentClass}`}
  {...entry.$?.blocks}
>
  {entry.page_components?.map((component, index) => (
    <div key={index} {...entry.$[`page_components__${index}`]}>
      <ComponentRenderer component={component} />
    </div>
  ))}
</div>;
```

The kickstart's Page.tsx uses this exact pattern.

## GraphQL Considerations

GraphQL responses have a different shape than REST responses, and addEditableTags expects the REST shape. Two things need to happen before tagging works:

- Include system fields in every query so you have UIDs


- Reshape the response to match what addEditableTags expects



### The Query

Always request system { uid, content_type_uid } on every entry and referenced entry. For assets, use the Connection pattern that GraphQL requires:

```typescript
query Page($url: String!) {
  all_page(where: { url: $url }) {
    items {
      system {
        uid
        content_type_uid
      }
      title
      description
      url
      imageConnection {
        edges {
          node {
            url
            title
          }
        }
      }
      blocks {
        ... on PageBlocksBlock {
          __typename
          block {
            title
            copy
            layout
            imageConnection {
              edges {
                node {
                  url
                  title
                }
              }
            }
          }
        }
      }
    }}}
```

### Reshaping for Edit Tags

addEditableTags expects uid and _content_type_uid at the root of the entry (not nested under system), and flat asset fields like image (not imageConnection.edges[].node). Reshape the GraphQL response before tagging:

```typescript
const fullPage = res?.all_page?.items?.[0];

const entry = fullPage && {
  ...fullPage,

  // Hoist system fields to root where addEditableTags expects themu
  id: fullPage.system?.uid,
  _content_type_uid: fullPage.system?.content_type_uid,

  // Flatten asset connections to plain fields
  image: fullPage.imageConnection?.edges?.[0]?.node,

  // Same treatment for modular blocks with nested connections
  blocks: fullPage.blocks?.map((block) => ({
    ...block,
    block: {
      ...block?.block,
      image: block?.block?.imageConnection?.edges?.[0]?.node || null,
    },
  })),
};
```

### Then Tag as Usual

Once reshaped, the standard addEditableTags call works exactly like the REST approach:

```typescript
import contentstack from "@contentstack/delivery-sdk";

if (entry) {
  contentstack.Utils.addEditableTags(entry, "page", true);
}

// entry.$ is now populated - components use entry.$?.title as usual
```

This reshaping pattern is used in the [Next.js GraphQL Kickstart](https://github.com/contentstack/kickstart-next-graphql). The key principle: get the data into the shape addEditableTags expects, then everything downstream (components, Visual Builder, edit tags) works identically regardless of whether you used REST or GraphQL to fetch it.

## Testing Edit Tags

- Open Live Preview in Contentstack


- Click elements on the page


- Verify the correct field opens in the entry editor



Action

Expected Result

Click headline

Title field opens

Click body text

Body field opens

Click item in list

That specific item's field opens (correct index)

Click referenced content

Referenced entry opens

If clicks open the wrong field, inspect the element's data-cslp value in devtools and compare the path to your content model structure. Off-by-one index errors and block type name typos are the most common causes.

## Preventing Drift

Since addEditableTags generates paths from the entry data itself, edit tags stay in sync with your content model automatically. You don't maintain a separate mapping. The main risk is in your components: if you rename a field in the content model, entry.$?.old_field_name silently returns undefined and the element loses its edit tag.

To catch this:

- TypeScript types matching your content model surface renamed or removed fields at compile time


- Integration tests that check elements have data-cslp attributes in the rendered output catch missing tags before deployment


- Visual Builder's DOM scanner highlights elements without tags during preview - if something stops being clickable, the field path changed



## Key Takeaways

- Edit tags (data-cslp) map rendered HTML to your content model. Without them, the CMS sees generic HTML.


- Format: {content_type_uid}.{entry_uid}.{locale}.{field_path}. Wrong path = wrong field opens.


- Call addEditableTags in your data layer, not in components.


- Tag what editors modify. Over-tagging clutters; under-tagging frustrates.


- Visual Builder scans the DOM for data-cslp attributes. No new data flow.


- Keep field paths in sync with your content model to prevent silent drift.



## Check Your Understanding

- An editor clicks a headline in Visual Builder and the wrong field opens in the CMS. What's the most likely cause, and how would you diagnose it?


- Your page renders a list of features from a modular block. The first three items work correctly in Visual Builder, but clicking the fourth item opens the third item's field. What's wrong?


- Your page includes an author card that renders data from a referenced entry. Should the edit tag point to the referencing field on the page entry or to the referenced author entry? Why?


- A colleague proposes adding data-cslp to every <div> wrapper in the component tree for maximum coverage. What's the practical downside?






---

## Debugging, Pitfalls, and Best Practices

What you'll be able to do after this chapter:

- Systematically isolate Live Preview failures using the 6-step debugging sequence instead of guessing


- Identify the five common failure modes by their symptoms and trace each to its root cause


- Apply the preview checklist to any new page or component to catch issues before they reach production



## Why this matters

Live Preview symptoms (stale content, no updates, wrong entry) rarely point directly to the cause. This chapter gives you a repeatable diagnostic process that isolates the problem layer by layer.

Most Live Preview problems fall into a small number of categories. A systematic approach isolates them faster than guessing.

## The Five Common Failure Modes

### 1. Missing or Dropped Hash

Symptoms: Preview shows published content instead of draft. First page works, navigation shows published content. "Preview randomly stops working."

Causes: Hash not extracted from URL. Hash not passed to content fetching. Navigation loses hash. Middleware strips query parameters. Redirects drop the hash.

Investigation: Check the URL in the preview iframe - is live_preview=... present? Check network requests - are they going to preview or delivery endpoints?

### 2. Cached Preview Responses

Symptoms: Changes don't appear even though preview seems connected. Old content persists. Different editors see each other's drafts.

Causes: CDN caching preview responses. Application-level caching not bypassed. Database persisting preview content.

Investigation: Check response headers for Cache-Control. Check your caching logic - does it check for the hash before caching?

### 3. Shared SDK Instances in SSR

Symptoms: One editor's changes appear in another's preview. Preview shows wrong entry's content. Inconsistent behavior under load.

Causes: Global Contentstack SDK instance mutated per request. Preview state stored in module-level variables.

Investigation: Is the SDK instantiated once globally, or per request? Do you call livePreviewQuery() on a shared instance?

### 4. Incorrect SDK Configuration

Symptoms: Live Preview panel loads but doesn't update. Changes trigger no response.

Causes: ssr: true set for CSR site (or vice versa). SDK not initialized in browser context. Wrong clientUrlParams.host for your region. SDK initialized too late.

Investigation: Check your ContentstackLivePreview.init() call. Is ssr correct? Is the host correct?

### 5. Wrong API Endpoint

Symptoms: Preview shows published content. API calls succeed but return old data.

Causes: Using delivery endpoint when preview needed. Preview token not included. Hash not included.

Investigation: Check network requests - what hostname is being called? What headers are included?

## Systematic Debugging

When Live Preview isn't working, walk through these steps in order:

flowchart LR

  hash{"Hash in URL?"}

  sdk{"SDK initialized?"}

  events{"Events firing?"}

  api{"Preview API?"}

  caching{"Caching disabled?"}

  rerender{"UI re-render?"}


  hash --> sdk --> events

  hash --> api --> caching --> rerender

### Step 1: Is the Hash in the URL?

Open devtools, find the preview iframe, check its src for live_preview=.... If missing, check Stack Settings > Live Preview and Environment Base URL configuration. Enable "Display Setup Status" for real-time feedback.

### Step 1b: Can the Iframe Load Your Site?

If the preview panel is blank, check for X-Frame-Options: DENY or strict CSP frame-ancestors. Either allow https://*.contentstack.com or use "Always Open in New Tab" (SDK v4.0.0+).

### Step 2: Is the SDK Initializing?

```typescript
// Empty string / undefined → not inside an active preview session (or init not run yet)console.log('SDK hash:', ContentstackLivePreview.hash);
console.log('SDK config:', ContentstackLivePreview.config);
```

If not initializing: verify init() is called, runs in browser context, and executes early in the lifecycle.

### Step 3: Are Change Events Firing?

```typescript
ContentstackLivePreview.onEntryChange(() => {
  console.log('Entry change event received'); // Should log once per save/edit when handshake is healthy
});
```

Make an edit. If no log appears: check the handshake completed, verify the ssr setting matches your architecture.

### Step 4: Is the Correct API Being Used?

Log your fetch calls - verify the endpoint is rest-preview.contentstack.com (or regional equivalent) and headers include preview_token and live_preview.

### Step 5: Is Caching Disabled?

Check response headers for Cache-Control: no-store. Check your code - does any caching logic run when the hash is present?

### Step 6: Is the New Data Being Rendered?

If fetches return correct data but the UI doesn't update: check state updates, verify re-renders, look for framework-level caching.

## Common Pitfalls by Architecture

### CSR

- Initializing SDK after first fetch


- Not subscribing to changes (onEntryChange never registered)


- Stale closures capturing old state/props


- Memory leaks from missing cleanup



### SSR

- Global SDK instance instead of request-scoped


- Hash not extracted per request


- Hash lost on navigation


- Caching preview responses



### SSG

- No preview mode enabled


- Client-side patching causing hydration mismatches


- Preview mode cookie/session not set correctly


- Draft mode detection not checking live_preview param



## Best Practices

### Be Deliberately Conservative

- Treat preview as runtime state, not environment config


- Isolate preview logic so it's easy to reason about


- Prefer determinism over optimization - refetch everything rather than selectively


- Document your preview architecture for future maintainers



### Preview Checklist

For every page or component that supports Live Preview:

- SDK initializes before content fetch


- Hash is extracted from current request (SSR) or SDK (CSR)


- Content is fetched from Preview API when hash present


- Caching is bypassed when hash present


- Navigation preserves preview parameters


- Edit tags are applied (if using Visual Builder)



## Quick Reference

Symptom

First Check

Likely Cause

Published content in preview

URL has hash?

Hash missing/dropped

Old content persists

Cache-Control header?

Caching preview responses

Wrong entry's content

SDK per-request?

Shared SDK instance

No updates after edit

Event callback fires?

SDK not initialized/configured

Preview works then stops

Hash in navigation URLs?

Hash lost on navigation

Iframe blank / connection error

X-Frame-Options or CSP?

Site blocks iframe embedding

"SDK not initialized" in setup UI

SDK init code present?

SDK missing or server-only

"Outdated SDK" warning

SDK version?

Update to v4.0.0+

"Preview Service not enabled"

Using preview endpoints?

Migrate to Preview Service

## Contentstack's Built-in Setup Status

Contentstack includes a troubleshooting UI that surfaces configuration errors in real time. Enable it at Settings > Live Preview > "Display Setup Status" toggle.

It checks for:

- Could not connect to website: CORS, X-Frame-Options, or incorrect Base URL


- Live Preview SDK not initialized: SDK init postMessage not received


- Outdated Live Preview SDK version: Update to v4.0.0+


- Preview Service not enabled: Follow the [Migrate to Preview Service](https://www.contentstack.com/docs/developers/set-up-live-preview/migrate-to-preview-service) guide


- Default environment not set: Set one in Settings > Live Preview



## Key Takeaways

- Five failure categories: missing hash, cached responses, shared SDK instances, wrong SDK config, wrong API endpoint.


- Debug in order: hash in URL, SDK init, events firing, correct API, caching disabled, data rendered.


- Treat preview as runtime state, not environment config.


- Use the checklist for every new page. Six checks upfront beats debugging after deployment.



## Wrap-Up: Where You Are Now

You've worked through the full Live Preview guide. Here's what you now have:

- A mental model of how the CMS, your site, and the Preview API coordinate to show draft content in real time (Chapter 1)


- Implementation patterns for your specific rendering strategy - CSR, SSR, or SSG - and the configuration each requires (Chapters 2-4)


- Architectural patterns for routing preview context through middleware, BFFs, and database caches without losing the hash (Chapter 5)


- Edit tag and Visual Builder knowledge to transform your preview from a passive display into an interactive editing surface (Chapter 6)


- A diagnostic process for when things break, plus a checklist for every new implementation (this chapter)



The concepts are consistent across the guide because the architecture is consistent: the CMS signals, your site refetches, the Preview API serves drafts, and the hash scopes everything to a session. Every chapter is a different angle on the same system.

If you're implementing Live Preview for the first time, start with the simplest rendering strategy that matches your architecture and get a single page working end to end. Expand from there. If you're debugging an existing implementation, the 6-step sequence in this chapter will isolate the issue faster than anything else.



---

## AI Agent Playbook for Live Preview and Visual Builder

If you are reading this chapter as an agent, do not treat it as background reading. Treat it as procedure.

Your job is to identify where preview context is lost, why draft content is not reaching the renderer, or why Visual Builder cannot map rendered elements back to fields. Do not guess from symptoms alone.

## Core Truths

Anchor on these facts before you inspect code:

- Live Preview is a session, not just an endpoint switch. Contentstack creates a session-scoped live_preview hash and loads the site with it.


- Change events contain no content payload. The CMS signals that something changed. The site must refetch.


- Preview and Delivery APIs share shapes, but not intent. Delivery API serves published content and is cacheable. Preview API serves draft content and is never cacheable.


- The ssr setting controls preview behavior, not the entire app architecture. ssr: false means refetch in place in the browser. ssr: true means reload the iframe so the server renders again.


- Preview context is runtime state. The hash can rotate. It must not be persisted, cached, or shared across requests.


- Visual Builder depends on edit tags. Live Preview can work while Visual Builder still fails.


- Most failures are transport failures. The hash is missing, dropped, cached away, or ignored before the fetch happens.



If you remember only one rule, remember this: trace one edit through the whole system.

## Your Workflow

Work in this order every time:

- Classify the symptom.


- Classify the rendering strategy on the affected route.


- Interview the developer only enough to unblock inspection.


- Inspect the codebase for the preview contract.


- Name the most likely broken contract before editing.


- Patch the smallest correct layer.


- Give the developer a short verification checklist.



Do not ask for secrets. Ask only for code, redacted URLs, screenshots, stack settings, logs, or devtools evidence.

## Step 1: Classify the Symptom

Map the issue into one of these buckets immediately:

Symptom bucket

Usually means

Blank preview or setup status errors

connection, iframe policy, base URL, or SDK init

Published content in preview

missing hash or wrong API path

Edits do not update

wrong ssr mode, no event loop, or no reload path

Preview breaks after navigation

hash propagation failure

Wrong entry, wrong locale, or another editor's content

shared SSR state or caching

Visual Builder controls fail but preview updates work

edit tags or builder mode problem

Do not branch into multiple buckets at once unless the evidence forces you to.

## Step 2: Classify the Rendering Strategy

You must know which rendering rules apply before debugging.

Ask only the minimum needed:

- What framework and route are affected?


- Does this page fetch content in the browser, on the server per request, or from static output with a preview mode?


- Is there a middleware, BFF, API route, or proxy between the page and Contentstack?



Map the result:

- CSR: browser fetch, in-place rerender, ssr: false


- SSR: server fetch per request, iframe reloads, ssr: true


- SSG preview: static in production, dynamic in preview mode


- Middleware/BFF: preview state must survive an extra hop



If the route mixes strategies, isolate the exact path the affected page uses before proceeding.

## Step 3: Interview the Developer

Ask for evidence, not opinions.

Request only what helps you prove or eliminate a failure family:

- A redacted preview URL or screenshot showing whether live_preview is present


- The code path for ContentstackLivePreview.init()


- The code path where preview vs delivery is chosen


- One failing network request showing hostname and cache headers


- A screenshot of Contentstack's "Display Setup Status" panel if the page is blank or disconnected


- The data-layer function where addEditableTags() should run if Visual Builder is involved



Never ask for:

- API keys


- preview tokens


- cookies


- raw auth headers


- entire .env files



## Step 4: Audit the Repo

Start with targeted searches:

```bash
rg -n "ContentstackLivePreview|onEntryChange|onLiveEdit|live_preview|preview_token|rest-preview|addEditableTags|data-cslp|VB_EmptyBlockParentClass|draftMode|setPreviewData|Cache-Control|no-store|frame-ancestors|X-Frame-Options"
```

Then answer these questions from the code:

- Where is ContentstackLivePreview.init() called?


- Is ssr correct for the affected route?


- Where does the app switch between Preview API and Delivery API?


- Where is the hash read from?


- Can navigation, middleware, redirects, or rewrites drop query params?


- Is caching bypassed whenever preview is active?


- If Visual Builder is involved, where are edit tags generated and spread into the DOM?



## Step 5: Identify the Broken Contract

Use the following diagnosis tree.

### A. Blank Preview or Setup Status Errors

Check:

- Is the preview URL reachable directly in a browser tab?


- Does the site block embedding with X-Frame-Options or frame-ancestors?


- Is the stack's Live Preview Base URL correct for the environment and locale?


- Does browser-executed code on the affected route call ContentstackLivePreview.init()?


- If clientUrlParams.host is set, does it match the stack region?



Likely broken contract:

- the CMS can load the URL, but the page cannot initialize a valid preview session



Likely fixes:

- loosen iframe policy or use "Always Open in New Tab"


- correct base URL configuration


- move SDK init into browser code that actually runs on the previewed route



### B. Published Content in Preview

Check:

- Does the URL include live_preview=...?


- Do requests hit the preview host instead of the delivery host?


- Is a preview token configured when preview is active?


- Is the hash forwarded into the layer that actually fetches content?


- If a proxy or middleware exists, does it receive and forward the hash too?



Likely broken contract:

- preview context exists in the browser but never reaches the content fetch



Likely fixes:

- forward live_preview from URL or SDK into the fetch layer


- switch to Preview API when the hash is present


- configure preview host and preview token correctly



### C. Edits Do Not Update

Separate CSR behavior from SSR behavior first.

For CSR, check:

- Is ssr: false configured?


- Is onEntryChange() or onLiveEdit() registered?


- Does the callback refetch content?


- Does state get replaced instead of partially merged?


- Is the subscription missing, duplicated, or mounted too late?



For SSR, check:

- Is ssr: true configured?


- Does the iframe reload after an edit?


- Does the server receive live_preview on the next request?


- Is the server-side client request-scoped and preview-aware?



Likely broken contract:

- the CMS can signal change, but the route does not execute the correct refresh path



Likely fixes:

- correct the ssr setting


- initialize the SDK earlier


- register the change callback once


- make the CSR refetch deterministic


- preserve preview params on SSR reload requests



### D. Preview Breaks After Navigation

Treat this as a hash propagation bug until you prove otherwise.

Check:

- Do internal links preserve live_preview, content_type_uid, entry_uid, and locale where needed?


- Do redirects, rewrites, auth guards, or middleware strip query params?


- Does client-side routing rebuild URLs without preview context?



Likely broken contract:

- preview context exists on the initial load but is lost during route changes



Likely fixes:

- append preview params during navigation in preview sessions


- preserve search params in redirects and rewrites


- clone full URLs instead of overwriting search



### E. Wrong Entry, Wrong Locale, or Another Editor's Draft

Treat this as shared state or caching.

Check:

- Is the Contentstack client instantiated globally and mutated per request in SSR?


- Is livePreviewQuery() called on a shared SDK instance?


- Are preview responses cached at CDN, framework, proxy, memory, or database layers?


- Is any persistent storage holding preview responses?



Likely broken contract:

- preview state that should be request-scoped is being shared or cached



Likely fixes:

- create request-scoped clients for SSR and BFF layers


- disable caching whenever live_preview is present


- keep preview data out of long-lived caches and persistence layers



### F. Visual Builder Fails but Preview Updates Work

This is not a transport problem. It is a tagging problem until proven otherwise.

Check:

- Is the SDK in mode: "builder" where builder UX is required?


- Does the fetched entry pass through addEditableTags() in the data layer?


- Are $ props spread into real DOM nodes?


- Are referenced entries tagged with their own content type UID?


- Are modular block containers tagged correctly?


- Does an empty modular blocks parent use VB_EmptyBlockParentClass when needed?



Likely broken contract:

- the page renders content, but the builder cannot map DOM nodes back to entry fields



Likely fixes:

- call addEditableTags(entry, contentTypeUid, true, locale) immediately after fetching


- spread entry.$?.fieldName onto rendered elements


- tag referenced entries separately


- add VB_EmptyBlockParentClass to empty modular block parents



## Condensed Knowledge

Use this as a quick reference while you work.

### Session and Hash

- live_preview is the session-scoped hash


- it is runtime state, not environment config


- it can rotate


- it must not be stored in a database or long-lived cookie


- it must be present anywhere preview content is fetched



### Preview vs Delivery

- Delivery API: published, cacheable, safe for production


- Preview API: draft, requires preview token plus hash, never cacheable


- mixing them on one page creates inconsistent published/draft output



### Rendering Rules

- CSR: browser init, ssr: false, subscribe, refetch in place


- SSR: browser init, ssr: true, reload iframe, fetch draft content per request


- SSG: escape static output with preview mode or draft mode


- Middleware/BFF: decide preview vs delivery where the Contentstack fetch actually happens



### Visual Builder Rules

- Live Preview can work without edit tags


- Visual Builder cannot work without edit tags


- addEditableTags() is the safest way to generate data-cslp


- the generated $ props must be spread onto actual DOM nodes


- empty modular-block containers often need VB_EmptyBlockParentClass



### Setup Status Clues

If the developer can access Contentstack's setup status panel, use it early.

It can quickly reveal:

- website unreachable


- iframe blocked


- SDK not initialized


- outdated SDK


- Preview Service not enabled


- default environment not configured



## Editing Rules

When you patch the repo:

- fix the smallest layer that restores the preview contract


- do not refactor unrelated code while debugging preview


- do not move secrets into the client


- do not cache preview data to "make it faster"


- do not persist the hash


- do not keep a shared mutable preview client in SSR



If multiple fixes are possible, prefer the one that makes preview flow more explicit and request-scoped.

## Verification Checklist to Hand Back

After your fix, tell the developer to verify these exact behaviors in Contentstack:

- open the same entry again in Live Preview


- confirm the URL contains live_preview


- make a small text edit and confirm the page updates


- navigate to a second previewed page and confirm draft context remains intact


- if Visual Builder is involved, click a tagged field and confirm the correct field opens


- if modular blocks are involved, test add, reorder, and delete actions



## When to Escalate

Pause and explain the tradeoff before continuing if:

- the affected route mixes CSR and SSR in a way that obscures the preview contract


- the developer wants preview responses cached


- middleware or a custom router rewrites most URLs and you cannot yet prove hash preservation


- the issue appears to be in Contentstack stack configuration rather than repo code


- the failure is browser-only and you need screenshots, network traces, or setup status evidence



When escalating, summarize:

- what you proved


- what remains uncertain


- the smallest next piece of evidence needed



## Final Instruction

Do not diagnose Live Preview from symptoms alone.

Reduce the problem to a contract:

- the CMS must load the page with preview context


- the SDK must initialize in the correct mode


- the fetch layer must use preview services when preview is active


- caches must be bypassed


- the renderer must apply the new data


- Visual Builder must see correct edit tags



Find the broken link. Fix only that link first.

