make-visual-editor-editable
When to use
Make a Studio page fully editable in Contentstack's Visual Editor: every field clickable on the page, lists that can be added to, reordered and deleted from, an empty list that offers "+", and a brand-new entry that is editable before anyone types into the form. Each rule here fails silently: the page renders correctly and Visual Editor simply offers nothing.
Phrases: "nothing is clickable in Visual Editor", "Visual Builder opens but I can't edit", "can't add a block / reorder / delete in Visual Editor", "no + on an empty list", "new entry shows Your text here", "can't click an empty field", "image under an overlay can't be selected". Do NOT use for the per-component $ props and studioAttributes contract (that's register-component-cslp), or for the stack-level Live Preview switch (that's enable-visual-experience).
Make every field and list editable in Visual Editor
Checked against @contentstack/studio-react 1.9.0 and @contentstack/live-preview-utils 4.5.3, in their code and on a rendered page (2026-10-07). Visual Editor works from the data-cslp edit tags in the page's HTML, so each check below is a count of tags, not a look at the page.
1. Live Preview must run in builder mode
live-preview-utils defaults to mode: "preview", and its editing layer returns early unless the mode is builder. Visual Editor then loads the page, sits on "Initializing the editor…", and nothing can be clicked. The init options live in install-studio § the lib/contentstack snippets: mode: "builder", an explicit ssr, editButton.exclude: ["outsideLivePreviewPortal"] and editInVisualBuilderButton: { enable: false } (builder mode otherwise adds a floating "Start Editing" link to every page it runs on).
Check: open the page directly (not in Visual Editor) with a preview token set. With builder mode and the "Start Editing" button enabled, [data-testid="vcms-start-editing-btn"] appears, which proves the editing layer started. Then turn that button off again.
2. Lists need two kinds of tag
Visual Editor's list controls read two tags: the list tag <…>.<field> on an element around the whole list, and an item tag <…>.<field>.<N> on an element around each item. Move and delete need the item tags. Add needs both: it finds the list element from the item's tag.
| Trap | What happens | Fix |
|---|---|---|
| No wrapper marked as the list | No list tag at all, so no add buttons | The node holding the Repeater carries metadata.repeaterWrapper: true. Studio's UI sets it on the Repeater's parent; by API, set it yourself (author-composition-practice) |
| The Repeater's child is a Condition Block holding a Section, or a Section Slot | The item tag never reaches an element: a Section or Section Slot renders no element of its own, so every item tag is dropped | Make a Box the Repeater's only child and put the Condition Blocks inside it: Repeater → Box → Condition Blocks → Sections |
A Section scoped to the list field (selected_field: "blocks") with its Repeater bound to the Section root | The list renders, but the tags are looked up on the bare array, which has none: no list tag and no item tags | Scope the Section to the whole entry and bind the Repeater to <field> |
Worked example (measured): a page-builder template built in Studio's UI (Repeater over a modular-blocks field → Condition Block → Section per block type) rendered the list tag page_sections and the fields inside each block (page_sections.0.cta_block.label), but no page_sections.0 / .1 / .2. With a Box between the Repeater and the Condition Blocks, all three item tags appeared and nothing else changed.
Check, on the rendered HTML of an entry with at least one item:
curl -s "<page URL>" | grep -o 'data-cslp="[^"]*\.<field>\(\.[0-9]*\)\?"' | sort -u
Expect the <field> line and one <field>.<N> line per item.
3. An empty list should offer "+"
Visual Editor shows its "This page doesn't have any … added" panel, with a "+", inside an element that has the list tag and the class visual-builder__empty-block-parent, for a field that exists in the content type schema. It looks for that class only after a change to the page's children (a childList mutation), never at start-up, and never after a class change alone. So add the class in the browser after mount, inside the editor only, then append an empty comment node to trigger the scan:
useEffect(() => {
const el = ref.current; // the list wrapper, carrying the list tag
if (!el || window.self === window.top || el.children.length) return;
el.classList.add("visual-builder__empty-block-parent");
el.appendChild(document.createComment("")); // a childList change, so Visual Editor rescans
}, [itemCount]);
Checked in the live-preview-utils code, not yet clicked through in Visual Editor.
4. A new entry must be editable before anyone types
A new entry holds only the fields someone has filled. A missing or null field gets no edit tag (tags are built from the entry's own keys) and its component shows the registered default ("Your text here"). An empty image is the worst case: a binding to image.url has nothing to tag. Two changes fix it, and both are needed:
- Complete the entry.
studioSdk.init({ fetchTemplateEntry })(studio-react 1.9.0) receivesdefaultFetch(). Give every missing ornullfield an empty value from the content type schema:""for text,{ url: "" }for a file,[]for anything multiple, recursing into groups and blocks. Never touch existing values. The SDK tags the entry after this hook, so every field gets a tag.Fetch the schema with a plain Delivery API call (fetchTemplateEntry: async (ctx) => {const entry = await ctx.defaultFetch();return entry ? completeEntry(entry, await ctSchema(ctx.connectedContentType)) : entry;},GET /v3/content_types/<uid>?environment=…, cached per uid). Don't usestack.contentType(uid).fetch(): with Live Preview configured,delivery-sdkcallsgetDatawithout a params object and throws "Cannot set properties of undefined (setting 'live_preview')". A change tostudioSdk.initoptions needs a dev-server restart; hot reload keeps the old config. - Render the empty element. A component that returns nothing for an empty value has no element to tag. Render the element whenever its
$prop is present ({(headline || $headline) && <h1 {...$headline}>{headline}</h1>}), and give empty tagged elements a minimum size inside the editor so there is something to click.
Measured: with hero missing from Entry MB, the heading had no tag and showed "Your headline here". With the completer alone, the default text was gone but there was still no tagged element. With both changes, the page rendered <h1 data-cslp="test_mb.<uid>.en-us.hero.heading">.
5. Decorative overlays block selection
Visual Editor selects the nearest tagged ancestor of the clicked element. A gradient or scrim laid over an image (an absolutely positioned sibling, or a ::after on the wrapper) takes the click, so the image can never be selected. Give every decorative overlay pointer-events: none.
Acceptance
- Live Preview runs with
mode: "builder"andeditInVisualBuilderButton: { enable: false }. - Every list renders a
<field>tag and one<field>.<N>tag per item. - No Section or Section Slot is the Repeater's direct child: a Box is.
- An empty list's wrapper gets
visual-builder__empty-block-parentafter mount, inside the editor only. -
fetchTemplateEntrycompletes the entry, and a new entry's empty fields, the image included, render tagged elements. - Decorative overlays have
pointer-events: none.
Common pitfalls
| Pitfall | Why it bites | Fix |
|---|---|---|
| Judging Visual Editor by an existing, fully filled entry | Every trap in § 4 shows only on a new entry | Test with a new entry whose fields are all empty |
| Rendering the empty-list class on the server | The class is present before Visual Editor starts watching, and nothing changes after | Add it after mount, then append the comment node |
| Completing the entry on the visitor side only | The editor fetch is the one that needs the tags | Pass fetchTemplateEntry to the studioSdk.init the editor's page uses (server and client) |