Enable visual editing

Help us improve this documentation

The framework-agnostic documentation is under development. If you have suggestions for improving the content, let us know by sharing your Feedback at the bottom of this page.

This walkthrough explains how to enable WYSIWYG editing for content authors working in the SitecoreAI Page builder, which is a key development task with Sitecore. By the end of the walkthrough, the Page builder will display your front-end app in an iframe and enable content authors to edit page contents directly in the canvas:

The walkthrough provides code examples for Astro (v7) and Go (v1) front-end applications, and it describes how to:

  1. Configure your Sitecore environment variables
  2. Retrieve editing layout data using GraphQL
  3. Add metadata wrappers to the HTML
  4. Create /api/editing/config
  5. Create /api/editing/render
  6. Register the new endpoints
  7. Connect the Page builder to localhost
  8. Test visual editing
Before you begin

Configure your Sitecore environment variables

In addition to the identifiers you previously set up for content rendering, more identifiers are needed for your front-end app to securely communicate with the Page builder. In Node-based applications, these are typically stored in a .env file, but you should use the appropriate method for the language and framework you are building with.

Important

Secrets such as API keys, editing secrets, and Context IDs must never be hard-coded or exposed to the browser. It's best practice to store them in environment variables or a secrets manager instead.

NameDescriptionExample
SITECORE_EDITING_SECRETThe Sitecore editing secret.

Required for the Page builder to retrieve data from your front-end app.

Find the value in SitecoreAI Deploy > Projects > your project > Authoring environments > your environment > Developer settings > Environment variables > the value for SITECORE_EDITING_SECRET in the code block.

If you cannot see SITECORE_EDITING_SECRET, ensure Context > Preview is selected.
6lrpCn...

Also make sure you use the Preview Context ID, not the Live Context ID. Visual editing always accesses draft (unpublished) content, accessed using the Preview Context ID.

Retrieve editing layout data using GraphQL

The /api/editing/render endpoint uses the editing GraphQL query, which is different from the one used for normal page rendering.

Select your framework and follow the steps to make this query in your front-end app:

Note

Both editing endpoints share the same CORS logic. Before creating the endpoints, consider extracting ALLOWED_ORIGINS and getCorsHeaders into a shared module (for example, src/services/editingCors.js) and importing it in both endpoint files. The code examples below include the full implementation inline for clarity.

  • In src/services/sitecoreClient.js, add the following function after the existing fetchLayoutData function:

    // src/services/sitecoreClient.js - add after fetchLayoutData
    
    export async function fetchEditingData(
      site,
      itemId,
      language,
      { version, layoutKind = "final", editMode = "false" } = {},
    ) {
      const endpoint = import.meta.env.SITECORE_EDGE_PLATFORM_URL;
      const contextId = import.meta.env.SITECORE_EDGE_CONTEXT_ID;
    
      const query = `
        query EditingQuery(
          $siteName: String!
          $itemId:   String!
          $language: String!
          $version:  String
          $pageSize: Int = 1000
          $after:    String
        ) {
          item(path: $itemId, language: $language, version: $version) {
            rendered
          }
          site {
            siteInfo(site: $siteName) {
              dictionary(language: $language, first: $pageSize, after: $after) {
                results { key value }
                pageInfo { endCursor hasNext }
              }
            }
          }
        }
      `;
    
      const response = await fetch(endpoint, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "x-sitecore-contextid": contextId,
          // sc_editMode instructs Edge to return draft content and field metadata.
          // sc_layoutKind selects between final and shared layout variants.
          sc_editMode: editMode,
          sc_layoutKind: layoutKind,
        },
        body: JSON.stringify({
          query,
          variables: { siteName: site, itemId, language, version },
        }),
      });
    
      const result = await response.json();
      if (result.errors) {
        throw new Error(JSON.stringify(result.errors));
      }
    
      const item = result?.data?.item;
      if (!item) return null; // item not found
    
      // Parse the rendered layout data (same structure as the layout query).
      const rendered = item.rendered;
      const layoutData =
        typeof rendered === "string" ? JSON.parse(rendered) : rendered;
    
      // Collect the first page of dictionary entries.
      // TODO: For sites with large dictionaries, implement pagination using
      // result.data.site.siteInfo.dictionary.pageInfo.hasNext and endCursor.
      const dictionary = {};
      for (const { key, value } of result?.data?.site?.siteInfo?.dictionary
        ?.results ?? []) {
        dictionary[key] = value;
      }
    
      return { layoutData, dictionary };
    }

Note that these scripts retrieve the dictionary but don't consume it because the components don't contain translatable UI strings.

Add metadata wrappers to the HTML

Next, you add the <code> metadata blocks that the Page builder needs to identify placeholders, components, and individual fields in the HTML your app renders.

To add metadata wrappers:

  • Create src/services/editingRenderer.js and paste the following code:

    // src/services/editingRenderer.js
    
    const DEFAULT_UID = "00000000-0000-0000-0000-000000000000";
    
    function escHtml(str) {
      return String(str ?? "")
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;");
    }
    
    // --- Metadata code blocks ---
    
    function metaOpen(chrometype, id) {
      return `<code type="text/sitecore" chrometype="${chrometype}" class="scpm" kind="open" id="${escHtml(id)}"></code>`;
    }
    
    function metaClose(chrometype) {
      return `<code type="text/sitecore" chrometype="${chrometype}" class="scpm" kind="close"></code>`;
    }
    
    // metaField wraps rendered field HTML with field-chrome metadata code blocks.
    // The Page builder reads these to identify which fields a content author can
    // edit inline.
    //
    // fieldData must be the full field object (the object that contains both
    // "value" and, in edit mode, "metadata"). If no "metadata" key is present,
    // the rendered HTML is returned unchanged.
    export function metaField(fieldData, rendered) {
      const metadata = fieldData?.metadata;
      if (!metadata) return rendered;
      return (
        `<code type="text/sitecore" chrometype="field" class="scpm" kind="open">${JSON.stringify(metadata)}</code>` +
        rendered +
        `<code type="text/sitecore" chrometype="field" class="scpm" kind="close"></code>`
      );
    }
    
    // --- Editing-aware component renderers ---
    
    function renderImageEditing(fields) {
      const f = fields?.Image;
      const img = f?.value ?? f?.jsonValue?.value;
      if (!img?.src) return metaField(f, "");
      const imgTag = `<img src="${escHtml(img.src)}" alt="${escHtml(img.alt ?? "")}" width="${escHtml(img.width ?? "")}" height="${escHtml(img.height ?? "")}" />`;
    
      // General Link field, with jsonValue as a fallback for direct item queries.
      const linkField = fields?.TargetUrl;
      const link = linkField?.value ?? linkField?.jsonValue?.value;
      const content = link?.href
        ? `<a href="${escHtml(link.href)}"${link.target ? ` target="${escHtml(link.target)}"` : ""}>${imgTag}</a>`
        : imgTag;
    
      // Plain text caption field.
      const caption = fields?.ImageCaption?.value;
      const html = caption
        ? `<figure>${content}<figcaption>${escHtml(caption)}</figcaption></figure>`
        : `<figure>${content}</figure>`;
      return metaField(f, html);
    }
    
    function renderPromoEditing(fields) {
      for (const name of ["PromoText", "PromoText2", "PromoText3"]) {
        const f = fields?.[name];
        if (f?.value) return metaField(f, f.value);
      }
      return "";
    }
    
    function renderRichTextEditing(fields) {
      const f = fields?.Text;
      if (!f?.value) return metaField(f, "");
      return metaField(f, `<div>${f.value}</div>`);
    }
    
    function renderTitleEditing(fields, params) {
      const f = fields?.heading;
      if (!f?.value) return metaField(f, "");
      const classAttr = params?.cssClass
        ? ` class="${escHtml(params.cssClass)}"`
        : "";
      return metaField(f, `<h1${classAttr}>${escHtml(f.value)}</h1>`);
    }
    
    // editingPassthroughComponents is the set of structural wrapper components
    // that have no fields of their own and only forward nested placeholders.
    // Components registered here in componentMap use a passthrough renderer
    // that calls Placeholder. In editing mode that would bypass
    // renderPlaceholderEditing, stripping all rendering and field chromes from
    // every component nested inside the wrapper.
    //
    // Add any new passthrough/container components here alongside adding them
    // to componentMap.
    export const editingPassthroughComponents = new Set([
      "Container",
      "ContainerFullBleed",
      "PartialDesignDynamicPlaceholder",
    ]);
    
    // editingComponentMap maps Sitecore component names to editing-aware renderer
    // functions. Each renderer wraps its field output with metaField so that the
    // Page builder can activate inline field editing on click.
    //
    // ColumnSplitter is handled separately in renderComponentEditing because it
    // renders nested placeholders and needs the component's own uid.
    const editingComponentMap = {
      Image: renderImageEditing,
      Promo: renderPromoEditing,
      RichText: renderRichTextEditing,
      Title: renderTitleEditing,
    };
    
    // renderColumnSplitterEditing mirrors ColumnSplitter.astro but calls
    // renderPlaceholderEditing so that nested column placeholders have metadata
    // code blocks and accept component drops in the Page builder canvas.
    function renderColumnSplitterEditing(fields, params, placeholders, uid) {
      const enabled = params?.EnabledPlaceholders
        ? params.EnabledPlaceholders.split(",").map((s) => s.trim())
        : null;
    
      const cols = Object.entries(placeholders)
        .map(([key, items]) => {
          const m = key.match(/^column-(\d+)-/);
          if (!m) return null;
          const index = Number(m[1]);
          if (enabled && !enabled.includes(String(index))) return null;
          return { key, items, index };
        })
        .filter(Boolean)
        .sort((a, b) => a.index - b.index);
    
      let html = `<div data-component="ColumnSplitter" class="${escHtml(params?.GridParameters ?? "")}">`;
      for (const col of cols) {
        const colClass = params?.[`ColumnWidth${col.index}`] ?? "";
        html += `<div data-column="${col.index}" class="${escHtml(colClass)}">`;
        html += renderPlaceholderEditing(col.key, col.items, uid);
        html += "</div>";
      }
      html += "</div>";
      return html;
    }
    
    // renderPlaceholderEditing renders a placeholder wrapped in metadata code
    // blocks. parentUID is the uid of the route or component that owns this
    // placeholder.
    export function renderPlaceholderEditing(
      name,
      items,
      parentUID = DEFAULT_UID,
    ) {
      const phId = `${name}_${parentUID}`;
      return (
        metaOpen("placeholder", phId) +
        `<div data-placeholder="${escHtml(name)}">` +
        (items ?? []).map((item) => renderComponentEditing(item)).join("") +
        "</div>" +
        metaClose("placeholder")
      );
    }
    
    // renderComponentEditing renders a single component wrapped in metadata code
    // blocks. It delegates field rendering to editingComponentMap (which adds
    // field-chrome wrapping), with a fallback to renderMissingComponentEditing
    // for any components not listed in editingComponentMap.
    export function renderComponentEditing(comp) {
      const componentName = comp?.componentName ?? "";
      const uid = comp?.uid || DEFAULT_UID;
      const fields = comp?.fields ?? {};
      const params = comp?.params ?? {};
      const rawPH = comp?.placeholders ?? {};
      const placeholders = Object.fromEntries(
        Object.entries(rawPH).map(([k, v]) => [k, Array.isArray(v) ? v : []]),
      );
    
      let inner;
      if (componentName === "ColumnSplitter") {
        // ColumnSplitter renders nested placeholders; it needs the component uid
        // to build correct placeholder IDs for drag-and-drop in the canvas.
        inner = renderColumnSplitterEditing(fields, params, placeholders, uid);
      } else if (editingPassthroughComponents.has(componentName)) {
        // Passthrough components have no fields of their own - they only forward
        // nested placeholders. Delegating to the normal component map would call
        // Placeholder instead of renderPlaceholderEditing, stripping all chromes
        // from every component nested inside the wrapper.
        inner = Object.entries(placeholders)
          .map(([key, items]) => renderPlaceholderEditing(key, items, uid))
          .join("");
      } else if (editingComponentMap[componentName]) {
        inner = editingComponentMap[componentName](fields, params, placeholders);
      } else {
        inner = renderMissingComponentEditing(componentName, placeholders, uid);
      }
    
      return metaOpen("rendering", uid) + inner + metaClose("rendering");
    }
    
    // renderMissingComponentEditing renders unmapped components in editing mode,
    // preserving their nested placeholders with metadata wrappers.
    export function renderMissingComponentEditing(
      componentName,
      placeholders,
      uid = DEFAULT_UID,
    ) {
      const inner = Object.entries(placeholders)
        .map(([key, items]) => renderPlaceholderEditing(key, items, uid))
        .join("");
      return `<div data-missing-component="${escHtml(componentName)}">${inner}</div>`;
    }
    
    // buildEditingHeadScripts extracts the three categories of editing scripts
    // from sitecore.context and returns them as a combined block of <script>
    // tags ready to embed in <head>:
    //
    // 1. clientScripts - JS bundle URLs that the Page builder provides. Loading
    // these activates the editing UI overlays inside the canvas iframe.
    // 2. hrz-canvas-state - JSON identifying the item, site, language, and mode
    // for the canvas. The Page builder reads this on load.
    // 3. hrz-canvas-verification-token - token that verifies the communication
    // channel between the iframe and the Page builder.
    export function buildEditingHeadScripts(ctx) {
      let html = "";
    
      // 1. Emit a <script src="..."> for each URL in clientScripts.
      for (const url of ctx?.clientScripts ?? []) {
        if (typeof url === "string") {
          html += `<script type="text/javascript" src="${escHtml(url)}"></script>`;
        }
      }
    
      const clientData = ctx?.clientData ?? {};
    
      // 2. Emit canvas state and verification token from clientData.
      if (clientData["hrz-canvas-state"] !== undefined) {
        html += `<script id="hrz-canvas-state" type="application/json">${JSON.stringify(clientData["hrz-canvas-state"])}</script>`;
      }
    
      if (typeof clientData["hrz-canvas-verification-token"] === "string") {
        html += `<script id="hrz-canvas-verification-token" type="application/json">${escHtml(clientData["hrz-canvas-verification-token"])}</script>`;
      }
    
      return html;
    }
    
    // renderLayoutEditing returns a complete HTML editing page string.
    //
    // headHtml is a string of <link> and <script> tags copied from your Astro
    // layout component's <head>. Passing these tags here ensures the editing
    // canvas loads with the same styles and scripts as the published site.
    // Astro API routes cannot call .astro components directly, so these tags
    // must be provided explicitly. Leave headHtml empty if your layout has no
    // external assets.
    export function renderLayoutEditing(layoutData, headHtml = "") {
      const sitecore = layoutData?.sitecore ?? {};
      const route = sitecore?.route;
      const ctx = sitecore?.context ?? {};
      const editingHeadScripts = buildEditingHeadScripts(ctx);
    
      if (!route) {
        return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Page not found</title>${headHtml}${editingHeadScripts}</head><body><main><p>Layout data is missing for this route.</p></main><script id="jss-hrz-editing" type="application/json">{}</script></body></html>`;
      }
    
      const routeUID = route.uid ?? DEFAULT_UID;
      const phs = route.placeholders ?? {};
      const title = escHtml(route.displayName || route.name || "");
    
      const header = renderPlaceholderEditing(
        "headless-header",
        phs["headless-header"] ?? [],
        routeUID,
      );
      const main = renderPlaceholderEditing(
        "headless-main",
        phs["headless-main"] ?? [],
        routeUID,
      );
      const footer = renderPlaceholderEditing(
        "headless-footer",
        phs["headless-footer"] ?? [],
        routeUID,
      );
    
      return `<!DOCTYPE html>
    
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>${title}</title>
        ${headHtml}
        ${editingHeadScripts}
    </head>
    <body>
        <header>${header}</header>
        <main>${main}</main>
        <footer>${footer}</footer>
        <script id="jss-hrz-editing" type="application/json">{}</script>
    </body>
    </html>`;
    }
  • In src/components/componentMap.js, add ContainerFullBleed alongside the other passthrough components you registered when following the content rendering walkthrough:

    // src/components/componentMap.js
    import PartialDesignDynamicPlaceholder from './PartialDesignDynamicPlaceholder.astro';
    
    export const componentMap = {
      // ...existing entries...
      'ContainerFullBleed': PartialDesignDynamicPlaceholder,
    };

Create /api/editing/config

The /api/editing/config endpoint tells the Page builder which editing mode your app uses and which component names it supports. The Page builder calls this endpoint every time it connects to an editing host. For example, when a content author opens the editor, or when you change between local and hosted editing hosts. For the response payload this endpoint must return, see the config response.

To create the /api/editing/config endpoint:

  • Create src/pages/api/editing/config.js and paste the following code:

    // src/pages/api/editing/config.js
    
    export const prerender = false;
    
    import { componentMap } from "../../../components/componentMap.js";
    
    // editingAllowedOrigins contains the domains from which the Page builder
    // makes requests.
    const ALLOWED_ORIGINS = [
      "https://pages.sitecorecloud.io",
      "https://app.sitecorecloud.io",
    ];
    
    function getCorsHeaders(origin) {
      if (ALLOWED_ORIGINS.includes(origin)) {
        return {
          "Access-Control-Allow-Origin": origin,
          "Access-Control-Allow-Headers": "Authorization, Content-Type",
          "Access-Control-Allow-Methods": "GET, OPTIONS",
          // Required when an HTTPS page (pages.sitecorecloud.io) accesses
          // a private-network address (localhost). See the Private Network
          // Access spec: https://developer.chrome.com/blog/private-network-access-preflight
          "Access-Control-Allow-Private-Network": "true",
        };
      }
      return {};
    }
    
    // Handle CORS preflight.
    export function OPTIONS({ request }) {
      const origin = request.headers.get("Origin") ?? "";
      return new Response(null, { status: 204, headers: getCorsHeaders(origin) });
    }
    
    // Handle GET /api/editing/config.
    // The Page builder calls this endpoint to confirm metadata editing mode support.
    export function GET({ request }) {
      const origin = request.headers.get("Origin") ?? "";
      const url = new URL(request.url);
      const corsHeaders = getCorsHeaders(origin);
    
      const secret = import.meta.env.SITECORE_EDITING_SECRET;
      if (!secret || url.searchParams.get("secret") !== secret) {
        return new Response(
          JSON.stringify({ message: "Missing or invalid editing secret" }),
          {
            status: 401,
            headers: { "Content-Type": "application/json", ...corsHeaders },
          },
        );
      }
    
      // Build the list of registered component names from the component map.
      const components = Object.keys(componentMap);
    
      // Return the config JSON.
      // editMode must be "metadata" to use metadata editing mode.
      // components is the list of Sitecore component names your app can render.
      // packages is an optional map of package versions; leave empty for custom apps.
      return new Response(
        JSON.stringify({ editMode: "metadata", components, packages: {} }),
        {
          status: 200,
          headers: { "Content-Type": "application/json", ...corsHeaders },
        },
      );
    }

Create /api/editing/render

The /api/editing/render endpoint is the core of the editing integration. When a content author views a page in the editor, the Page builder loads the editing canvas by setting the iframe src to this endpoint. The browser then makes a GET request to your app with the editing parameters in the URL. Your app retrieves the editing layout data from Sitecore and returns a complete HTML page to the Page builder, with metadata code blocks embedded. The Page builder will load this HTML page inside an iframe.

Important

If your app sets X-Frame-Options: SAMEORIGIN or a Content-Security-Policy header with frame-ancestors 'self', the iframe will be blocked. Either remove those headers for the editing render endpoint responses, or add https://pages.sitecorecloud.io as an allowed framing origin.

To create the /api/editing/render endpoint:

  • Create src/pages/api/editing/render.js and paste the following code:

    // src/pages/api/editing/render.js
    
    export const prerender = false;
    
    import { fetchEditingData } from "../../../services/sitecoreClient.js";
    import { renderLayoutEditing } from "../../../services/editingRenderer.js";
    
    const ALLOWED_ORIGINS = [
      "https://pages.sitecorecloud.io",
      "https://app.sitecorecloud.io",
    ];
    
    function getCorsHeaders(origin) {
      if (ALLOWED_ORIGINS.includes(origin)) {
        return {
          "Access-Control-Allow-Origin": origin,
          "Access-Control-Allow-Headers": "Authorization, Content-Type",
          "Access-Control-Allow-Methods": "GET, OPTIONS",
          // Required when an HTTPS page (pages.sitecorecloud.io) accesses
          // a private-network address (localhost).
          "Access-Control-Allow-Private-Network": "true",
        };
      }
      return {};
    }
    
    // Handle CORS preflight.
    export function OPTIONS({ request }) {
      const origin = request.headers.get("Origin") ?? "";
      return new Response(null, { status: 204, headers: getCorsHeaders(origin) });
    }
    
    // Handle GET /api/editing/render.
    // The Page builder calls this endpoint for every page view inside the editor.
    export async function GET({ request }) {
      const url = new URL(request.url);
      const q = url.searchParams;
      const secret = import.meta.env.SITECORE_EDITING_SECRET;
    
      if (!secret || q.get("secret") !== secret) {
        return new Response(
          JSON.stringify({ message: "Missing or invalid editing secret" }),
          { status: 401, headers: { "Content-Type": "application/json" } },
        );
      }
    
      const site = q.get("sc_site") ?? "";
      const itemId = q.get("sc_itemid") ?? "";
      const lang =
        q.get("sc_lang") || import.meta.env.SITECORE_SITE_LANGUAGE_CODE || "en";
      const mode = q.get("mode") ?? "preview";
      const version = q.get("sc_version") ?? undefined;
      const layoutKind = q.get("sc_layoutKind") || "final";
    
      // sc_editMode: "true" returns draft content and embeds editing scripts in
      // sitecore.context; "false" returns published content only.
      const editMode = mode === "edit" ? "true" : "false";
    
      let data;
      try {
        data = await fetchEditingData(site, itemId, lang, {
          version,
          layoutKind,
          editMode,
        });
      } catch (err) {
        return new Response(String(err), { status: 500 });
      }
    
      if (!data) {
        return new Response("Item not found", { status: 404 });
      }
    
      // Pass the <link> and <script> tags your Astro layout component includes
      // in <head> so that the editing canvas loads with the correct styles and
      // scripts. Astro API routes cannot call .astro components directly, so
      // these tags must be provided here explicitly. Copy them from your layout
      // component (for example src/layouts/Layout.astro) and keep them in sync
      // when your layout changes. Leave headHtml empty if your layout has no
      // external assets.
      const headHtml = "";
      const html = renderLayoutEditing(data.layoutData, headHtml);
      const origin = request.headers.get("Origin") ?? "";
    
      return new Response(html, {
        status: 200,
        headers: {
          "Content-Type": "text/html; charset=utf-8",
          ...getCorsHeaders(origin),
        },
      });
    }

Register the new endpoints

After creating the endpoints, update your HTTP server and register the endpoints.

To register the new endpoints:

Astro uses file-based routing, so creating the files in the previous two procedures automatically registers the endpoints at /api/editing/config and /api/editing/render, with no additional registration required.

Connect the Page builder to localhost

Your app is now ready to be loaded into the Page builder iframe. You can connect the Page builder directly to your running local server without deploying. This lets you test components without affecting other SitecoreAI users.

To connect the Page builder to localhost:

  1. In astro.config.mjs, add security.allowedDomains and vite.server.cors:

    // astro.config.mjs
    import { defineConfig } from "astro/config";
    import node from "@astrojs/node";
    
    export default defineConfig({
      output: "server",
      security: {
        // Allow the SitecoreAI Page builder origins through Astro's
        // cross-origin security middleware so requests from these domains
        // reach the editing API endpoints. Requires Astro >= 5.14.2.
        allowedDomains: [
          { hostname: "pages.sitecorecloud.io", protocol: "https" },
          { hostname: "app.sitecorecloud.io", protocol: "https" },
        ],
      },
      vite: {
        server: {
          // Disable Vite's built-in CORS middleware so that OPTIONS preflight
          // requests from pages.sitecorecloud.io (an HTTPS origin accessing
          // localhost, a private network address) are not intercepted by Vite
          // before reaching the Astro API route handlers. The editing endpoints
          // handle CORS themselves and include the Access-Control-Allow-Private-
          // Network header required by the Private Network Access spec.
          cors: false,
        },
      },
      adapter: node({ mode: "standalone" }),
    });
    • Without security.allowedDomains, Astro returns 403 Forbidden for every request from pages.sitecorecloud.io before your endpoint code runs, regardless of the CORS headers your endpoints set.
    • Without vite.server.cors: false, Vite's built-in CORS middleware intercepts every OPTIONS preflight from pages.sitecorecloud.io and responds with Access-Control-Allow-Origin: *, but never adds the Access-Control-Allow-Private-Network: true header. When an HTTPS page accesses a private network address (localhost), browsers require this header in the preflight response before sending the real request. The browser blocks the request and your Astro OPTIONS handler never runs.
  2. Start your development server:

    npm run dev
  3. Open your site in the Page builder.

  4. In the Page builder, on the ribbon directly above the canvas, to the right of the Version selector, click Default editing host, select Local host, and then enter your app's localhost address, such as http://localhost:4321.

  5. Click Save.

  6. Verify that the Page builder loads your app in the canvas.

  7. Test visual editing.

Test visual editing

After the Page builder displays your app in the canvas, test visual editing to make sure common editing interactions work.

To test visual editing:

  1. Start or restart your development server.
  2. Open your site in the Page builder and connect it to localhost.
  3. Verify that the Page builder loads your app in the canvas.
  4. In your web browser's developer console, on the Network tab, find a request to /api/editing/config, and read the request and response details. A successful /api/editing/config response returns {"editMode":"metadata","components":[...]}.
  5. Make a change to a field. For example, edit a text, and then verify that the change appears in localhost after you refresh the page.
  6. Drag a component to a different position in the canvas and verify that the change appears in localhost after you refresh the page.

Next steps

You've now set up your front-end app in localhost to appear and be editable in the Page builder.

Next, you can:

If you have suggestions for improving this article, let us know!