Visual Builder - GraphQL, localization, and edge cases
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 && (
<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 (
<>
>
);
}
When something goes wrong, these debugging techniques help narrow down the issue quickly.
Debugging tools
When Visual Builder is not working as expected, several debugging approaches help identify the issue:
Browser console messages
The Live Preview SDK logs diagnostic messages to the browser console. Look for:
- Initialization messages: Confirm the SDK initialized successfully and detected the iframe context.
- postMessage events: Check if messages are being sent and received between the parent window and the iframe.
- Error messages: The SDK reports errors for invalid data-cslp attributes, missing configuration, and communication failures.
Open the browser developer tools while in Visual Builder and filter the console for "contentstack" or "live-preview" to see relevant messages.
Verifying data-cslp attributes
Use the browser's element inspector to verify that data-cslp attributes are present and correctly formatted on your rendered elements. Check for:
- Missing attributes on elements that should be editable.
- Incorrect field paths (wrong dot notation, missing block index, wrong content type UID).
- Hardcoded locale values that do not match the current editing locale.
Network tab inspection
In the browser's Network tab, verify that:
- Preview requests go to the correct preview host for your region, not the production CDN endpoints.
- The preview_token header is present on preview requests.
- The live_preview hash header is present during active Live Preview sessions.
- Response status is 200 and contains the expected draft content.
Multi-language documentation site example
Consider a documentation site built with Next.js App Router, using the GraphQL CDA with Visual Builder, supporting English (en-us), Spanish (es), and Japanese (ja-jp).
// app/[locale]/docs/[...slug]/page.tsx
import { draftMode } from "next/headers";
import { graphqlFetchWithPreview } from "@/lib/graphql-client";
import { DocsPageClient } from "./DocsPageClient";
const DOCS_QUERY = `
query GetDocsPage($url: String!, $locale: String!) {
all_page(where: { url: $url }, locale: $locale) {
items {
uid
title
body
last_updated
seo {
meta_title
meta_description
}
sidebar_links {
label
url
}
}
}
}
`;
export default async function Page({
params,
}: {
params: { locale: string; slug: string[] };
}) {
const dm = await draftMode();
const isPreview = dm.isEnabled;
const locale = params.locale;
const url = `/docs/${params.slug.join("/")}`;
const data = await graphqlFetchWithPreview(
DOCS_QUERY,
{ url, locale },
isPreview ? undefined : undefined // hash managed by SDK in SSR
);
const entry = data?.all_page?.items?.[0];
if (!entry) return Not found;
return (
);
}
// app/[locale]/docs/[...slug]/DocsPageClient.tsx
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import ContentstackLivePreview from "@contentstack/live-preview-utils";
import { getContentstackEndpoints } from "@timbenniks/contentstack-endpoints";
const endpoints = getContentstackEndpoints(
process.env.NEXT_PUBLIC_CONTENTSTACK_REGION || "na",
true
);
interface DocsPageClientProps {
entry: any;
locale: string;
isPreview: boolean;
}
export function DocsPageClient({ entry, locale, isPreview }: DocsPageClientProps) {
const router = useRouter();
useEffect(() => {
if (!isPreview) return;
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();
});
}, [isPreview, router]);
return (
{entry.title}
);
}
In this setup, an editor can open the Japanese version of a documentation page, see it rendered with the Japanese locale routing (/ja-jp/docs/getting-started), click on the title to edit it in Japanese, switch to Spanish in the locale dropdown, and see the preview update to show the Spanish version of the same page. Visual Builder overlays work for all locales because the data-cslp attributes use the dynamic locale variable rather than a hardcoded en-us literal string.
Common mistakes
Mistake 1: using GraphQL field aliases in data-cslp values
If your GraphQL query aliases a field (pageTitle: title), the data-cslp attribute must still use the original schema field UID (title), not the alias (pageTitle). Visual Builder resolves field identifiers against the content type schema, not against your query structure parameters.
Mistake 2: hardcoding locale in data-cslp for multi-locale sites
Setting data-cslp string literals with hardcoded language paths causes the platform to launch the wrong localization overlay workspace when editors transition between localized tabs. Always derive locale arguments dynamically from the route context metadata.
Mistake 3: omitting frame-ancestors CSP directive on the preview host
Without the frame-ancestors directive allowing app.contentstack.com, the browser blocks the iframe entirely, displaying a blank screen. This is an environment configuration error that is easily missed because the preview site works perfectly when accessed directly outside the iframe workspace window.
Self-check
- Your team uses GraphQL with field aliases (heroTitle: title) throughout the codebase. After enabling Visual Builder, editors cannot click on any aliased field. Explain why and describe the fix.
- Visual Builder works perfectly when you access the preview site directly in a browser tab, but shows a blank panel when opened from the Contentstack entry editor. What is the most likely cause, and what header change resolves it?
- Your production site uses Astro in full static mode. A new requirement asks for Visual Builder support. Describe the hosting architecture you would propose and explain why static output alone cannot support it.