Reading time: 11 min read

Sitecore Pages Edit Mode without an official SDK: The Handshake, in Astro

Sitecore Pages edit mode is a handshake any server-rendered app can implement: two endpoints, a shared secret, CORS and CSP headers, and one preview data call. We build it by hand in Astro so you can see every piece, and point to the shortcuts if you just want it working.

Portrait photo of Sohrab Saboori, article author

Edit mode is a contract, not a framework feature

Making edit mode in Sitecore Pages actually work is one of the hardest parts of a headless Sitecore project, and this post is for the case where your framework has no official Sitecore package. Sitecore ships first-party Content SDK packages for Next.js and, since September 2026, Angular (Content SDK for Angular 1.0); those hide the edit-mode wiring behind a route handler or two. Everything else gets the raw contract, and almost none of it is framework-specific: the handshake between Sitecore Pages and your app is the same on Next.js, Astro, or anything else that serves HTML per request. Only the syntax changes. We walk through what Pages sends, what your app has to answer, and what the editing secret is for, with Astro for the working code because Astro is what we built.

If you just want edit mode working in Astro, you have two shortcuts and don't need to read further. Our open-source starter contains all of the code below. And EXDST maintains a community Astro adapter for the Content SDK (@exdst-sitecore-content-sdk/astro), built on the same core package, with Pages metadata editing wired in. It isn't a Sitecore product, so weigh its maintenance the way you would any third-party dependency. This post is for understanding what either of those is doing on your behalf.

What "edit mode" actually means

When a content author clicks Edit in Sitecore Pages, the editor doesn't hit your production site directly. The flow, as Sitecore's editing architecture page describes it, comes down to four moves (Sitecore: editing architecture):

  1. Sitecore Pages calls your /api/editing/config endpoint to confirm your app supports metadata editing and to learn which components it exposes.
  2. Sitecore Pages calls your /api/editing/render endpoint with the route and item it wants to edit.
  3. Your endpoint renders the page in edit mode and returns the full HTML document.
  4. Sitecore Pages loads that HTML in an iframe and overlays its editing chrome on top.

Everything else, sc_mode=edit, sc_itemid, the editing secret, is plumbing that supports those four steps.

Notice that none of the four steps mention a framework. Any app that can expose two HTTP endpoints and render a page server-side can do this. That's the whole contract.

There are two things that make it harder than it looks:

  • Edit mode is per-request. Static output won't work. You need server rendering.
  • Sitecore Pages runs on a different origin. You're rendering inside its iframe, so CORS, CSP, and a shared secret all matter.

Any server-rendering framework can meet both requirements. In Astro that means output: 'server' with an adapter, so pages render on demand (Astro on-demand rendering). Here's how the rest looks.

The editing secret

Every editing request from Sitecore Pages carries a secret query parameter. Your endpoint compares it to a server-side environment variable, conventionally named SITECORE_EDITING_SECRET (Sitecore: editing architecture):

SITECORE_EDITING_SECRET=<random-token>

The same value goes into your SitecoreAI environment, under Deploy, your project, the authoring environment, Developer settings, Environment variables (Sitecore: enable visual editing). This is the only real authentication on these endpoints. An Origin header can be set by any HTTP client, so the CORS check alone proves nothing about who is calling /api/editing/render. Treat it like a webhook secret: generate it once, store it in env vars on both sides, never commit it.

One more setting matters here. The server-side Edge context ID your app uses must be the Preview context ID, not the Live one, because visual editing always reads draft content (Sitecore: enable visual editing). If your editor shows only published content no matter what you do, check this before anything else.

Endpoint 1: /api/editing/config

Sitecore Pages calls this first, with GET and OPTIONS, to find out what your app supports: the edit mode, the list of components, and the package versions it's running (Sitecore: editing architecture). This is the GET handler from our starter; the OPTIONS handler runs the same origin check and returns 204:

// src/pages/api/editing/config.ts
const COMPONENTS = ['HeroST', 'HeaderST', 'SignupBanner'];
const CLIENT_COMPONENTS = ['HeaderST'];
export const GET: APIRoute = ({ request, url }) => {
  const cors = corsHeadersFor(request.headers.get('origin'));
  if (!cors) return new Response('Invalid origin', { status: 401 });
  const expected = import.meta.env.SITECORE_EDITING_SECRET;
  const provided = url.searchParams.get('secret');
  if (!expected || provided !== expected) {
    return new Response(JSON.stringify({ message: 'Missing or invalid editing secret' }), {
      status: 401,
      headers: { 'Content-Type': 'application/json', ...cors },
    });
  }
  return new Response(JSON.stringify({
    framework: 'astro',
    components: COMPONENTS,
    clientComponents: CLIENT_COMPONENTS,
    packages: {
      'astro': '6.x',
      '@sitecore-content-sdk/core': '1.4.x',
    },
    editMode: 'metadata',
  }), { status: 200, headers: { 'Content-Type': 'application/json', ...cors } });
};

Astro maps exported functions named after HTTP methods to requests, so GET and OPTIONS are just two exports in one file (Astro endpoints).

Two guardrails matter here.

CORS allowlist. Only Sitecore's editing hosts should be able to call this endpoint cross-origin. We use the same list the Content SDK ships as EDITING_ALLOWED_ORIGINS: pages.sitecorecloud.io, xmapps.sitecorecloud.io, and designlibrary.sitecorecloud.io (Sitecore/content-sdk on GitHub). Sitecore's docs give pages.sitecorecloud.io and app.sitecorecloud.io as examples (Sitecore: editing architecture), so if your editor reports an invalid origin, compare the host in the error with your list before anything else.

Secret check. A request without a secret query parameter, or with the wrong one, gets a 401.

We allowlist the origins explicitly rather than echoing whatever the client sent. That's the difference between safe CORS and a wide-open endpoint.

Endpoint 2: /api/editing/render

This is the workhorse. Sitecore Pages sends route, sc_site, sc_itemid, sc_lang, mode, and optionally sc_version, sc_variant, and sc_layoutKind (Sitecore: editing architecture). Your endpoint renders the page in edit mode and returns HTML.

The simplest correct implementation: proxy back to your own catch-all route with the right query parameters. This is the starter's handler with its error responses trimmed:

// src/pages/api/editing/render.ts (simplified)
export const GET: APIRoute = async ({ request, url, site }) => {
  const cors = corsHeadersFor(request.headers.get('origin'));
  if (!cors) return new Response('origin not allowed', { status: 401 });
  if (url.searchParams.get('secret') !== import.meta.env.SITECORE_EDITING_SECRET) {
    return new Response('invalid secret', { status: 401 });
  }
  const route = url.searchParams.get('route');
  const target = new URL(route!, site?.toString() ?? `${url.protocol}//${url.host}`);
  target.searchParams.set('sc_mode', url.searchParams.get('mode') ?? 'edit');
  target.searchParams.set('sc_site', url.searchParams.get('sc_site') ?? 'sync');
  target.searchParams.set('sc_lang', url.searchParams.get('sc_lang') ?? 'en');
  for (const key of ['sc_itemid', 'sc_version', 'sc_layoutKind', 'sc_variant']) {
    const v = url.searchParams.get(key);
    if (v) target.searchParams.set(key, v);
  }
  const upstream = await fetch(target.toString(), {
    headers: { cookie: request.headers.get('cookie') ?? '' },
  });
  return new Response(await upstream.text(), {
    status: upstream.status,
    headers: {
      'Content-Type': 'text/html; charset=utf-8',
      'Content-Security-Policy': `frame-ancestors 'self' https://pages.sitecorecloud.io https://xmapps.sitecorecloud.io https://designlibrary.sitecorecloud.io`,
      ...cors,
    },
  });
};

Three pieces of this are easy to get wrong.

The Content-Security-Policy: frame-ancestors header. It lists the parents allowed to embed your page in an iframe (MDN: frame-ancestors). Browsers allow framing when no such header is present, so this line is not what makes the iframe work.

Why it still matters. Most production setups send X-Frame-Options or a restrictive frame-ancestors, and the moment one of those reaches the editing response, Sitecore Pages shows an empty frame with only a "refused to display" line in the console. Setting frame-ancestors on the editing response to exactly your CORS allowlist, which is Sitecore's guidance (Sitecore: editing architecture), lets Pages in and keeps everyone else out. The Content SDK's Next.js render handler sets the same header.

Cookie forwarding. The iframe request arrives from the author's browser, so the internal call to your own catch-all should carry the same cookies. Otherwise anything that depends on them, such as a protected preview deployment or a site-resolution cookie, behaves differently inside the editor than outside it. The Content SDK's Next.js render handler propagates the incoming request headers to its internal render call for the same reason.

The catch-all route, with edit-mode awareness

Back in [...path].astro we already had the published-page flow. Adding edit mode is one branch. Trimmed from the starter, which also accepts the SDK's alternate parameter names and logs the fallback:

---
const sp = Astro.url.searchParams;
const isEditMode = sp.get('sc_mode') === 'edit';
const itemId = sp.get('sc_itemid');
let page = null;
if (isEditMode && itemId) {
  page = await client.getPreview({
    site: sp.get('sc_site') ?? 'sync',
    itemId,
    language: sp.get('sc_lang') ?? 'en',
    version: sp.get('sc_version') ?? '1',
    layoutKind: sp.get('sc_layoutKind') ?? 'final',
    mode: 'edit',
    variantIds: (sp.get('sc_variant') ?? '').split(',').filter(Boolean),
  });
}
if (!page) {
  // fall back to published content
  page = await client.getPage(routePath, { site, locale });
}
---

client.getPreview() is doing real work: it asks Sitecore Edge for the editing layout of that exact item, language, and version, with the requested personalization variants applied, and returns it in the shape Pages needs to overlay its chrome. client.getPage() resolves by route and returns the plain layout, which is right for visitors and wrong for the editor.

The fallback is intentional. If getPreview fails, because of an expired token, a wrong item ID, or a transient error, the editor still gets something useful, the last published version, instead of a 500.

Injecting the Pages chrome with EditingScripts

Sitecore Pages adds an editing overlay on top of your rendered HTML: selectable fields, the components panel, drag-and-drop. For that to attach, your page must include the script URLs and the JSON client data that came back with the editing layout, plus a marker that tells Pages your app supports metadata editing (Sitecore: editing architecture). The Content SDK's getContentSdkPagesClientData() supplies that marker: an empty JSON script tag with the id jss-hrz-editing, which Pages looks for before activating.

---
// src/components/EditingScripts.astro
import { getContentSdkPagesClientData } from '@sitecore-content-sdk/core/editing';
const { context } = Astro.props;
const clientScripts = context?.clientScripts ?? [];
const clientData = {
  ...(context?.clientData ?? {}),
  ...getContentSdkPagesClientData(),
};
---
{clientScripts.map((src) => )}
{Object.entries(clientData).map(([id, data]) => (
  <script id={id} type="application/json" is:inline 
    set:html={JSON.stringify(data).replace(/</g, '\\u003c')}></script> 
))}

We render this only in edit mode:

{isEditMode && <EditingScripts context={sitecoreContext} />}}

Two Astro-specific details.

is:inline. It stops Astro from bundling or hoisting the script tag, so the tag passes through to the browser as written (Astro template directives).

Escaping <. Replacing < in the JSON script tags prevents field content from breaking out of the tag and injecting HTML.

Don't cache edit-mode responses

The published-page branch sets a CDN-friendly cache header. Edit mode skips it:

if (!isEditMode) {
  Astro.response.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
}

If you forget this, content authors will publish a change, hit refresh in Pages, and see the previous draft for the next minute. Subtle, infuriating bug.

A mental model for the whole flow

Author clicks Edit in Sitecore Pages
        │
        ▼
Sitecore Pages → GET /api/editing/config (with secret)
        │   (confirms metadata edit mode, discovers your components)
        ▼
Sitecore Pages → GET /api/editing/render?route=/...&sc_itemid=...&secret=...
        │
        ▼
Your /api/editing/render handler
        │   - checks secret + CORS
        │   - rebuilds target URL with sc_mode=edit + sc_itemid
        │   - fetches your own [...path].astro
        ▼
[...path].astro
        │   - sees sc_mode=edit
        │   - calls client.getPreview() (not getPage)
        │   - renders placeholders + 
        ▼
HTML response → /api/editing/render → Sitecore Pages iframe
        │
        ▼
Pages overlays editing chrome on the rendered HTML

Once you have this picture, every piece has a job: the secret authenticates the request, CORS scopes it to Sitecore origins, the CSP allows iframing, getPreview gets unpublished content, EditingScripts lets Pages overlay its chrome.

Common things that go wrong

SymptomLikely cause
Editor shows blank iframeAn X-Frame-Options or restrictive frame-ancestors header is reaching the editing response; set frame-ancestors to Sitecore's hosts
Editor shows "Invalid origin"CORS allowlist is missing the Sitecore Pages host
Editor shows published content, not draftThe server-side context ID is the Live one instead of Preview
Page renders but nothing is editableclient.getPage is being called instead of client.getPreview, so the layout has no editing metadata
Editor overlay doesn't activateEditingScripts not rendered in edit mode (or is:inline missing)
Random 401sSITECORE_EDITING_SECRET differs between your app and the Sitecore environment

Wrap-up

Sitecore Pages edit mode looks complicated because it has so many moving parts: secret, CORS, CSP, two endpoints, two SDK methods. But each piece is small, and none of it is tied to a particular framework. Two API routes and one edit-mode branch in your page rendering is the entire surface, whatever stack you're on. That is why a framework without an official Sitecore package is not locked out of visual editing; it just has to write the handshake down instead of importing it.

Sitecore now documents this path officially. The framework-agnostic development docs cover project setup, content rendering, and visual editing, with Astro among the example stacks, so what this post describes is a supported route rather than a workaround.

Source code. Everything above is implemented in our open-source Astro starter at github.com/rikaweb/astro-sitecore-content-sdk, on Astro 6 and Content SDK 1.4; this post explains what that code does. If you'd rather not own the endpoints yourself, the community EXDST Astro adapter wraps the same handshake. This is Part 2 of a series: Part 1 covers the Astro setup, and Part 3 compares the same site built on Astro and on Next.js.

Sources

  1. Astro + Sitecore Content SDK starter (source code)
  2. Part 1: Connecting Sitecore XM Cloud to Astro — Fishtank
  3. Editing architecture — Sitecore documentation, framework-agnostic development
  4. Enable visual editing — Sitecore documentation, framework-agnostic development
  5. Introduction to framework-agnostic Sitecore development — Sitecore documentation
  6. Sitecore/content-sdk — official Content SDK repository on GitHub
  7. Content SDK for Angular 1.0 released — Sitecore changelog, September 10, 2026
  8. EXDST Astro Content SDK adapter (community)
  9. Endpoints — Astro documentation
  10. On-demand rendering — Astro documentation
  11. Template directives reference — Astro documentation
  12. CSP: frame-ancestors — MDN Web Docs