Headless & Composable CMS · 10 min read

Contentstack Visual Builder with Next.js App Router: Setup Guide

Visual editing that works in the Next.js App Router: preview deployment, per-request SDK client, edit tags and the iframe headers that block it.

By Praful Patel

Short answer

Contentstack’s Visual Builder (now called Visual Editor in Contentstack’s docs) loads your Next.js site in an iframe and maps every click to a CMS field through data-cslp edit tags. To get it working in the App Router you need four things: Live Preview working first, @contentstack/live-preview-utils initialised in the browser with mode: "builder", edit tags added on the server with addEditableTags, and a preview deployment that fetches drafts with a per-request client and never caches them.

Most failed Visual Builder integrations do not fail loudly. The site loads in the editor, then nothing is clickable, or clicks open the wrong field, or edits show up only after a manual refresh. Each symptom traces back to one specific piece of wiring. This guide goes through that wiring for the Next.js 16 App Router with Server Components, using the current SDKs (@contentstack/live-preview-utils 4.x, @contentstack/delivery-sdk 5.x and @contentstack/utils).

A note on naming: Contentstack’s documentation now says “Visual Editor”, but the SDK still uses mode: "builder" and editInVisualBuilderButton. Both names refer to the same product. For the wider integration (Delivery API, webhooks, images), see Contentstack and Next.js: building a composable front end.

How the pieces fit

Piece Runs Job
Visual Editor Contentstack app Loads your page in an iframe and adds query parameters, including live_preview (the session hash)
Delivery SDK with live_preview config Your server Uses the hash and a preview token to fetch draft content from the preview host
addEditableTags (@contentstack/utils) Your server, in the data layer Adds a $ object to each entry, holding data-cslp attributes per field
live-preview-utils Browser Talks to the editor over postMessage, scans the DOM for edit tags and draws the editing overlay

SSR or CSR mode?

The SDK’s ssr option decides how edits reach the page.

ssr: true ssr: false
On each edit The SDK asks the editor to reload the iframe, and your server re-renders with fresh data The SDK fires onEntryChange and your client code refetches in place
Fits Server Components fetching data Client-rendered pages
Preview token Stays on the server The browser needs a configured stack SDK
Feel A visible reload per change Smoother

For an App Router site that fetches in Server Components, use SSR mode. The reload is the price of keeping one data path for preview and production, and of keeping the preview token off the client.

Step 1: a separate preview deployment

Run preview from its own deployment, for example preview.example.com, and set it as the base URL of your preview environment in Contentstack. Only that deployment holds the preview token and loads the preview SDK. This is the simplest way to guarantee that production can never serve a draft. A visitor who adds ?live_preview=anything to a production URL reaches a server that has no preview credentials.

Step 2: a stack client per request

livePreviewQuery() stores the hash on the stack instance. A module-level singleton shared by concurrent requests could leak one editor’s session into another request, which is why Contentstack’s App Router SSR guide creates a client per request.

// lib/contentstack.ts
import 'server-only';
import contentstack from '@contentstack/delivery-sdk';

export const isPreview = process.env.CONTENTSTACK_PREVIEW === 'true';

export type PreviewParams = { hash?: string; contentTypeUid?: string; entryUid?: string };

export function getStack(preview?: PreviewParams) {
  const stack = contentstack.stack({
    apiKey: process.env.CONTENTSTACK_API_KEY!,
    deliveryToken: process.env.CONTENTSTACK_DELIVERY_TOKEN!,
    environment: process.env.CONTENTSTACK_ENVIRONMENT!,
    live_preview: {
      enable: isPreview,
      preview_token: process.env.CONTENTSTACK_PREVIEW_TOKEN, // only set on the preview deployment
      host: process.env.CONTENTSTACK_PREVIEW_HOST ?? 'rest-preview.contentstack.com', // region-specific
    },
  });

  if (isPreview && preview?.hash) {
    stack.livePreviewQuery({
      live_preview: preview.hash,
      contentTypeUid: preview.contentTypeUid,
      entryUid: preview.entryUid,
    });
  }
  return stack;
}

Step 3: fetch, tag and cache in the data layer

Add edit tags where the data is fetched, not inside components, and only on the preview deployment. Production HTML then carries no editing metadata. Preview reads must never be cached: the same URL with the same hash returns different content seconds later while the editor types.

// lib/pages.ts
import 'server-only';
import { QueryOperation } from '@contentstack/delivery-sdk';
import { addEditableTags } from '@contentstack/utils';
import { cacheLife, cacheTag } from 'next/cache';
import { getStack, isPreview, type PreviewParams } from './contentstack';
import type { PageEntry } from './types'; // generated from your content types, plus `$?`

async function fetchPage(url: string, preview?: PreviewParams) {
  const result = await getStack(preview)
    .contentType('page').entry().query()
    .where('url', QueryOperation.EQUALS, url)
    .find<PageEntry>();
  return result.entries?.[0] ?? null;
}

async function getPublishedPage(url: string) {
  'use cache';                 // Cache Components; invalidated by your publish webhook
  cacheLife('days');
  cacheTag('contentstack:page');
  return fetchPage(url);
}

export async function getPage(url: string, preview?: PreviewParams) {
  if (!isPreview) return getPublishedPage(url);

  const entry = await fetchPage(url, preview); // uncached on purpose
  if (entry) addEditableTags(entry, 'page', true, 'en-us'); // tagsAsObject: true for JSX spreads
  return entry;
}

addEditableTags changes the entry in place, adding a $ object at each level. With tagsAsObject: true, each value is an attribute object you can spread straight into JSX. Referenced entries need their own call with their content type UID (for example addEditableTags(entry.author[0], 'author', true, 'en-us')), so that a click opens the referenced entry rather than the page. The publish-webhook side of cacheTag is covered in Next.js caching explained.

Step 4: render the tags

// app/[[...slug]]/page.tsx
import { notFound } from 'next/navigation';
import { getPage } from '@/lib/pages';
import { isPreview } from '@/lib/contentstack';
import { BlockRenderer } from '@/components/block-renderer';

// Same value as VB_EmptyBlockParentClass from @contentstack/live-preview-utils.
// Hard-coded so this Server Component doesn't import the browser SDK.
const EMPTY_BLOCK_PARENT = 'visual-builder__empty-block-parent';

export default async function Page({ params, searchParams }: PageProps<'/[[...slug]]'>) {
  const { slug } = await params;
  // Only the preview deployment reads searchParams, so production routes stay static.
  const sp: Record<string, string | string[] | undefined> = isPreview ? await searchParams : {};
  const one = (v: string | string[] | undefined) => (typeof v === 'string' ? v : undefined);

  const page = await getPage(`/${slug?.join('/') ?? ''}`, {
    hash: one(sp.live_preview),
    contentTypeUid: one(sp.content_type_uid),
    entryUid: one(sp.entry_uid),
  });
  if (!page) notFound();

  return (
    <main>
      <h1 {...page.$?.title}>{page.title}</h1>

      {/* Parent tag: lets editors add, remove and reorder blocks */}
      <div
        className={EMPTY_BLOCK_PARENT}
        data-add-direction="vertical"
        {...page.$?.page_components}
      >
        {page.page_components?.map((block, i) => (
          // Per-item tag: key is `${fieldUid}__${index}`
          <div key={i} {...page.$?.[`page_components__${i}`]}>
            <BlockRenderer block={block} />
          </div>
        ))}
      </div>
    </main>
  );
}

On the preview deployment, reading searchParams makes the route render per request, which is what preview needs. With Cache Components enabled, give the route a loading.tsx or a <Suspense> boundary for that runtime read. On production, searchParams is never touched, so the route stays prerenderable, and the cached getPublishedPage serves it. Each block component spreads its own fields’ tags in the same way ({...block.hero.$?.headline}). The parent wrapper plus a tag on each item is what turns on add, delete and drag-to-reorder for a modular blocks field. A content model with a small, well-defined set of block types makes this much easier, as covered in Contentstack content modelling.

Step 5: initialise the browser SDK

// components/live-preview-init.tsx
'use client';
import { useEffect } from 'react';
import ContentstackLivePreview from '@contentstack/live-preview-utils';

export function LivePreviewInit() {
  useEffect(() => {
    ContentstackLivePreview.init({
      ssr: true,
      mode: 'builder',                 // "Start Editing" opens Visual Editor
      stackDetails: {
        apiKey: process.env.NEXT_PUBLIC_CONTENTSTACK_API_KEY!, // required for edit tags
        environment: process.env.NEXT_PUBLIC_CONTENTSTACK_ENVIRONMENT!,
      },
      clientUrlParams: { host: 'app.contentstack.com' }, // e.g. eu-app.contentstack.com for EU
      editButton: { enable: true },
    });
  }, []);
  return null;
}

// app/layout.tsx (excerpt)
// {process.env.CONTENTSTACK_PREVIEW === 'true' && <LivePreviewInit />}

Keep the initialisation in its own client component, as Contentstack recommends, so re-renders don’t reset its configuration. Render it only on the preview deployment. For production builds, the SDK’s README documents PURGE_PREVIEW_SDK=true, which swaps the SDK for a lightweight stub. The Live Preview Utils v4 reference lists every init option.

Step 6: let the editor frame your site

Visual Editor needs to load your preview deployment in an iframe. A strict X-Frame-Options header or a CSP without the right frame-ancestors gives you a blank panel. Allow the Contentstack app origin for your region, on the preview deployment only:

// next.config.ts (excerpt)
async headers() {
  if (process.env.CONTENTSTACK_PREVIEW !== 'true') return [];
  return [{
    source: '/:path*',
    headers: [{
      key: 'Content-Security-Policy',
      value: "frame-ancestors 'self' https://app.contentstack.com", // add your region's app host
    }],
  }];
},

Inside the iframe, your site is a third-party context, so cookies without SameSite=None; Secure may not be sent. That is one more reason to drive preview from the live_preview query parameter, which arrives on every reload, rather than from a cookie-based draft mode.

Edit tags: format and failure modes

A tag value has the form {content_type_uid}.{entry_uid}.{locale}.{field_path}, for example page.blt123.en-us.page_components.0.hero.headline. The Contentstack edit tags guide stresses that the field path must exactly match the content model, including the block UID segment inside modular blocks.

Symptom Likely cause
Page loads, nothing is clickable Tags not added (preview flag off, or addEditableTags not called), or stackDetails.apiKey missing from init
Click opens the wrong field A hand-written path that misses the block UID segment, or a reference tagged with the parent’s content type
Some elements are not selectable The tag was spread on a component that doesn’t pass props through to a DOM element
Edits only appear after a manual refresh A cached preview read, or a singleton stack that never received the new hash
Blank panel X-Frame-Options or frame-ancestors blocking the iframe

Checklist

  • Live Preview works on its own before you switch on mode: "builder".
  • The preview deployment is the environment’s base URL, and only it holds the preview token.
  • One stack client per request on preview, and preview reads are never cached.
  • addEditableTags runs in the data layer, on preview only, for the page and for each reference with its own content type.
  • Modular blocks have a parent tag plus a tag on each item.
  • The browser SDK is initialised once, in its own client component, only on preview.
  • The preview deployment allows the Contentstack app origin in frame-ancestors.

Questions people actually ask

Is Visual Editor the same as Visual Builder?
Yes. Contentstack’s current documentation uses “Visual Editor”. The SDK keeps the older naming (mode: "builder", editInVisualBuilderButton), so existing code does not need to change.

Why does Visual Editor show my site but no field is clickable?
Almost always missing edit tags or a missing API key. Check the rendered HTML for data-cslp attributes, and confirm stackDetails.apiKey is passed to init. If the attributes exist but clicks do nothing, compare their paths with the content model.

Editors stuck with a preview they don’t trust?

I set up and fix Contentstack Live Preview and Visual Editor on Next.js, covering preview deployments, edit tags, modular blocks and caching, so editors can click, edit and see the result straight away.

Get help with Contentstack preview →
← Back to all insights