Visual Builder - GraphQL, localization, and edge cases

Text Lesson8m 15sBeginnerReleased: July 31, 2026

Visual Builder: GraphQL, localization, and edge cases

TL;DR

  • GraphQL preview uses a separate endpoint (e.g., graphql-preview.contentstack.com) and requires the live_preview hash header for real-time editing.
  • data-cslp values always reference the content type schema field UID, not GraphQL aliases -- even if you alias fields in your query.
  • For multi-locale sites, the locale in data-cslp must be dynamic and match the content being rendered; hardcoding en-us breaks Visual Builder for every other locale.
  • Your preview host must include frame-ancestors 'self' https://app.contentstack.com in its CSP headers or Visual Builder cannot load it in the iframe.

Once the core Visual Builder implementation is working with the REST Content Delivery API, three categories of complexity remain: integrating with the GraphQL Content Delivery API, supporting multi-locale editing workflows, and handling the infrastructure edge cases that cause Visual Builder to fail silently in production environments. Each of these scenarios introduces specific configuration requirements that differ from the standard REST-based setup covered in Lesson 4.1.4.

Visual Builder with GraphQL

Contentstack offers both REST and GraphQL Content Delivery APIs. The Live Preview SDK supports both, but GraphQL requires additional configuration because the query structure and response shape differ from REST.

GraphQL preview endpoint

The standard GraphQL fields endpoint follows the pattern https://{region-prefix}graphql.contentstack.com/stacks/{api_key}. For preview, use the preview-specific GraphQL endpoint and pass the Preview Token instead of the Delivery Token. Every region has its own GraphQL preview host -- see the full endpoint table in Lesson 3.1.2 for all seven regions, or use @timbenniks/contentstack-endpoints to resolve them from a region string.

// lib/graphql-client.ts  -  Preview-aware GraphQL client
import { getContentstackEndpoints } from "@timbenniks/contentstack-endpoints";

const endpoints = getContentstackEndpoints(process.env.NEXT_PUBLIC_CONTENTSTACK_REGION || "na");
const isPreview = process.env.NEXT_PUBLIC_CONTENTSTACK_PREVIEW === "true";

const GRAPHQL_ENDPOINT = isPreview
  ? `${endpoints.graphqlPreview}/stacks/${process.env.NEXT_PUBLIC_CONTENTSTACK_API_KEY}`
  : `${endpoints.graphqlDelivery}/stacks/${process.env.NEXT_PUBLIC_CONTENTSTACK_API_KEY}`;

const AUTH_TOKEN = isPreview
  ? process.env.NEXT_PUBLIC_CONTENTSTACK_PREVIEW_TOKEN
  : process.env.NEXT_PUBLIC_CONTENTSTACK_DELIVERY_TOKEN;

export async function graphqlFetch(query: string, variables?: Record) {
  const response = await fetch(GRAPHQL_ENDPOINT, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      access_token: AUTH_TOKEN!,
      environment: process.env.NEXT_PUBLIC_CONTENTSTACK_ENVIRONMENT!,
    },
    body: JSON.stringify({ query, variables }),
    cache: isPreview ? "no-store" : "force-cache",
  });

  const result = await response.json();
  return result.data;
}

The live_preview hash with GraphQL

When using the Live Preview SDK with GraphQL, the live_preview hash must be passed as a query parameter or header on each GraphQL request during a live editing session. The SDK provides this hash when it detects an active Live Preview session.

import { getContentstackEndpoints } from "@timbenniks/contentstack-endpoints";

const endpoints = getContentstackEndpoints(process.env.NEXT_PUBLIC_CONTENTSTACK_REGION || "na");

export async function graphqlFetchWithPreview(
  query: string,
  variables?: Record,
  livePreviewHash?: string
) {
  const headers: Record = {
    "Content-Type": "application/json",
    access_token: process.env.NEXT_PUBLIC_CONTENTSTACK_PREVIEW_TOKEN!,
    environment: process.env.NEXT_PUBLIC_CONTENTSTACK_ENVIRONMENT!,
  };

  if (livePreviewHash) {
    headers["live_preview"] = livePreviewHash;
  }

  const response = await fetch(
    `${endpoints.graphqlPreview}/stacks/${process.env.NEXT_PUBLIC_CONTENTSTACK_API_KEY}`,
    {
      method: "POST",
      headers,
      body: JSON.stringify({ query, variables }),
      cache: "no-store",
    }
  );

  const result = await response.json();
  return result.data;
}

The hash ensures that the GraphQL endpoint returns the exact in-memory state the editor is currently working on, not just the last saved draft. Without it, there can be a delay between the editor's keystrokes and the content appearing in the preview.

Field paths in GraphQL vs REST responses

GraphQL responses follow the query structure you define, not the flat field structure of REST responses. This affects how data-cslp attribute values map to content.

Consider a documentation page content type with a title, body, and a group field seo containing meta_title and meta_description. The REST API returns:

{
  "uid": "blt9a8b7c6d5e4f3g2h",
  "title": "Our Story",
  "body": "<p>Welcome to the docs...</p>",
  "seo": {
    "meta_title": "Our Story | Docs",
    "meta_description": "Learn how to get started..."
  }
}

A GraphQL query for the same entry:

query GetDocPage($url: String!) {
  all_page(where: { url: $url }) {
    items {
      uid
      title
      body
      seo {
        meta_title
        meta_description
      }
    }
  }
}

The GraphQL response nests the data, but the field structure within the entry is identical to REST. The data-cslp attribute values remain the same regardless of whether you used REST or GraphQL to fetch the data:

// data-cslp values are the SAME for both REST and GraphQL
<h1 data-cslp="page.blt9a8b7c6d5e4f3g2h.en-us.title">
  {entry.title}
</h1><span data-cslp="page.blt9a8b7c6d5e4f3g2h.en-us.seo.meta_title">
  {entry.seo.meta_title}
</span>

Common pitfall:

If you alias fields in your GraphQL query (heroTitle: title) and then use the alias in data-cslp, Visual Builder cannot map the click to the correct field. The data-cslp must always use the original schema field UID (title), not the alias.

The data-cslp values always reference the content type schema path, not the GraphQL query path.

Initializing Live Preview SDK with GraphQL

When using GraphQL without the Contentstack JavaScript Delivery SDK, you do not pass a stackSdk instance to ContentstackLivePreview.init(). Instead, you manage the data flow manually through the onEntryChange callback:

import ContentstackLivePreview from "@contentstack/live-preview-utils";
import { getContentstackEndpoints } from "@timbenniks/contentstack-endpoints";

const endpoints = getContentstackEndpoints(
  process.env.NEXT_PUBLIC_CONTENTSTACK_REGION || "na",
  true
);

ContentstackLivePreview.init({
  ssr: true,
  enable: true,
  mode: "builder",
  stackDetails: {
    apiKey: process.env.NEXT_PUBLIC_CONTENTSTACK_API_KEY!,
    environment: process.env.NEXT_PUBLIC_CONTENTSTACK_ENVIRONMENT!,
  },
  clientUrlParams: {
    host: endpoints.application,
  },
  editButton: {
    enable: true,
    exclude: ["outsideLivePreviewPortal"],
  },
});

ContentstackLivePreview.onEntryChange(() => {
  router.refresh();
});

Without stackSdk, the Live Preview SDK cannot intercept and serve data from the message payload automatically. In CSR mode, this means you need to manually re-fetch the GraphQL query on each change. In SSR mode, triggering router.refresh() causes the server to re-run the GraphQL query with the updated live_preview hash.

With the GraphQL integration covered, the next area of complexity is multi-locale editing.

Localization with Visual Builder

Contentstack supports content localization across multiple locales. Visual Builder integrates with the locale system, allowing editors to switch between locales in the Contentstack UI and see the preview update to show the localized content.

How locale switching works in Visual Builder

When an editor switches the locale in the Contentstack entry editor (using the locale dropdown), the platform sends a postMessage to the preview iframe indicating the new locale. The Live Preview SDK detects this change and can trigger an automatic content refresh.

The behavior depends on how your framework handles locale routing:

Path-based locale routing (/en-us/docs/getting-started, /es/docs/primeros-pasos):

// The Live Preview SDK can trigger navigation to the locale-specific URL
ContentstackLivePreview.onEntryChange(() => {
  // The SDK provides the current locale context
  const locale = ContentstackLivePreview.getLocale();

  // Navigate to the locale-specific path
  const currentPath = window.location.pathname;
  const pathWithoutLocale = currentPath.replace(/^\/(en-us|es|ja)/, "");
  window.location.href = `/${locale}${pathWithoutLocale}`;
});

Query parameter or cookie-based locale routing:

ContentstackLivePreview.onEntryChange(() => {
  const locale = ContentstackLivePreview.getLocale();

  // Update the locale query parameter and refresh
  const url = new URL(window.location.href);
  url.searchParams.set("locale", locale);
  window.location.href = url.toString();
});

Tagging localized fields correctly

The locale component in the data-cslp attribute must match the actual locale of the content being rendered. If your page renders French content, the data-cslp must specify fr-fr, not en-us:

// Incorrect: hardcoding en-us when rendering French content
<h1 data-cslp="page.blt_matrix_link_001.en-us.title">
  Premiers pas
</h1>

// Correct: using the actual locale
<h1 data-cslp="{`page.blt_matrix_link_001.${currentLocale}.title`}">
  {entry.title}
</h1>

If the locale in data-cslp does not match the locale context in the Contentstack entry editor, Visual Builder cannot map the click to the correct localized field. The editor clicks on the French title, but Visual Builder tries to open the English version of that field.

 

Make the locale dynamic in your components:

function DocsPage({ entry, locale }: { entry: any; locale: string }) {
  return (
    
 ); }

Locale fallback behavior in preview

Contentstack supports locale fallback: if an entry is not localized for ja-jp, it can fall back to en-us. In preview mode, this means the preview might show English content when the editor has selected Japanese. This is correct behavior - it matches what production would show - but it can confuse editors who expect to see localized content.

To make fallback behavior transparent, consider adding a visual indicator in your preview deployment when content is falling back:

function LocalizedField({ entry, field, locale, fallbackLocale }: {
  entry: any;
  field: string;
  locale: string;
  fallbackLocale: string;
}) {
  const isLocalized = entry._locale === locale;

  return (
    <div classname="{isLocalized" ?="" ""="" :="" "locale-fallback"}="">
      <span data-cslp="{`page.${entry.uid}.${locale}.${field}`}">
        {entry[field]}
      </span>
      {!isLocalized &amp;&amp; (
        <small classname="fallback-indicator">
          Showing {fallbackLocale} fallback
        </small>
      )}
    </div>
  );
}

With GraphQL and localization in place, the remaining challenges are infrastructure edge cases that cause Visual Builder to fail silently.

Edge cases and troubleshooting

Iframe restrictions: CSP and X-Frame-Options

Visual Builder loads your preview application in an iframe hosted on the Contentstack domain (for example, app.contentstack.com). If your preview deployment sends headers that block iframe embedding, Visual Builder cannot render your application.

Two headers commonly cause this conflict:

  • X-Frame-Options: Setting this to DENY or SAMEORIGIN prevents any cross-origin iframe embedding. For your preview host, you'll want to either remove this header or allow explicit origin mapping targets.
  • Content-Security-Policy with frame-ancestors: The modern replacement for X-Frame-Options. Your preview deployment must include the Contentstack app domain in the directive:
Content-Security-Policy: frame-ancestors 'self' https://app.contentstack.com https://*.contentstack.com;

In Next.js, you configure this in next.config.js:

// next.config.js  -  Allow Contentstack to embed the preview site
const securityHeaders = [
  {
    key: "Content-Security-Policy",
    value: "frame-ancestors 'self' https://app.contentstack.com https://*.contentstack.com",
  },
];

module.exports = {
  async headers() {
    return [
      {
        source: "/:path*",
        headers: securityHeaders,
      },
    ];
  },
};

You'll want to avoid applying this permissive policy to your production deployment. Only the preview host should allow iframe embedding by the Contentstack domain.

Cross-origin issues

The Contentstack app runs on app.contentstack.com. Your preview runs on preview.example.com. The postMessage API works across origins, but other browser security features can interfere:

  • Cookies: If your preview application uses cookies for authentication or session management, they must be set with SameSite=None; Secure to work inside a cross-origin iframe context.
  • Storage access: In browsers with enhanced tracking protection, cross-origin iframes may lose access to localStorage and sessionStorage, meaning cached tokens or local tracking values do not persist.

Authentication-gated preview pages

Some preview deployments require authentication to prevent unauthorized access to draft content. When the preview is loaded inside the Contentstack iframe, the authentication flow must work within that iframe context:

  • Basic auth: HTTP basic auth prompts inside an iframe can be problematic. Some browsers block the authentication prompt entirely in cross-origin iframes. Consider using token-based auth instead.
  • OAuth/SSO redirects: Redirecting to an OAuth provider from within an iframe often fails because OAuth providers set X-Frame-Options: DENY on their login pages. Use a token or secret passed via query parameter instead:
// Preview authentication via shared secret in the URL
// The Contentstack preview URL includes the secret as a query parameter
// Settings > Live Preview > Preview URL: https://preview.example.com?preview_secret=your-secret

// Middleware to validate the preview secret
export function middleware(request: NextRequest) {
  const previewSecret = request.nextUrl.searchParams.get("preview_secret");

  if (previewSecret !== process.env.PREVIEW_SECRET) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  return NextResponse.next();
}

Previewing content with unpublished references

When an entry references another entry (for example, a blog post referencing an author), both entries must be available through the Preview API for preview to work correctly. If the blog post is a draft and the referenced author entry has never been saved, the preview will show the blog post but the author field will be empty or cause an error.

The Preview Token returns the latest saved state of every entry, so as long as the referenced entry has been saved at least once (even without publishing), it will appear in preview. The edge case occurs when new reference UIDs are missing, items are deleted, or reference targets live in an inaccessible branch context. Build defensive conditional rendering layouts for reference fields in preview mode:

function BlogPost({ entry, isPreview }: { entry: any; isPreview: boolean }) {
  const author = entry.author?.[0];

  return (
    
     {author ? (              ) : isPreview ? (              ) : null}    
 ); }

Static site generators and Visual Builder

If your production site uses static site generation (SSG) without a runtime server, Visual Builder cannot work with the static output. Static HTML files do not re-fetch content on each request, so Live Preview updates have no mechanism to reach the rendered page.

The solution is to maintain a separate preview deployment that uses SSR or CSR, even if production uses SSG:

  • Production: Static HTML generated at build time, deployed to a CDN. No server runtime required.
  • Preview: SSR deployment (for example, server mode configuration) that fetches draft content on each request and supports Live Preview.

Both deployments share the same codebase. The production build uses static export configurations, while the preview target runs in server mode. This is the same dual-deployment pattern described in Lesson 4.1.2, with the additional consideration that the build mode differs between the two.

Third-party scripts in iframe context

Some third-party scripts - analytics trackers, chatbots, A/B testing tools, and consent management platforms - behave unexpectedly or break entirely when loaded inside an iframe. Common issues include:

  • Analytics: The script detects the iframe context and records the Contentstack domain as the referrer, polluting production metrics. Exclude the preview host from analytics tracking loops.
  • Chatbots: Chat widgets attempt to resize or escape the iframe container, causing visual layout issues. Conditionally disable chat widgets in preview mode.
  • Consent banners: Cookie consent banners may not function correctly in cross-origin iframes due to browser storage access restrictions.

Conditionally disable these scripts in preview mode:

// Only load third-party scripts outside of preview mode
export function ThirdPartyScripts({ isPreview }: { isPreview: boolean }) {
  if (isPreview) return null;

  return (
    <>