Reading time: 6 min read

Connecting Sitecore XM Cloud to Astro, using the official Content SDK

Pairing the Sitecore Content SDK with Astro to build a fast, server-first CMS front end — Layout Service, GraphQL, and editing all handled.

Portrait photo of Sohrab Saboori, article author

How we connected Sitecore XM Cloud to Astro using the Official Content SDK

Astro is a fast, server-first web framework that ships zero JavaScript by default and lets you mix React, Vue, or Svelte components as islands. Sitecore XM Cloud is a headless CMS that exposes content through an Edge GraphQL API and a Layout Service. Pairing them sounds straightforward — but the "how" matters, because the wrong glue makes editor previews, multisite, and personalization much harder later.

This post walks through how we wired Sitecore XM Cloud into Astro using the official @sitecore-content-sdk/core package, why we skipped the other options, and how pages are built and rendered.

Source code: github.com/rikaweb/astro-sitecore-content-sdk — open-source starter for this post.

The options we considered

There is more than one way to pull Sitecore content into Astro. We looked at three:

OptionWhat it isWhy we didn't use it
Direct GraphQL callsHit Sitecore Edge GraphQL with fetch and a hand-written query per page.No Layout Service support, no editor preview, no personalization hooks. Fine for one card, painful for full pages.
Direct Layout Service RESTCall the Layout Service endpoint and walk the JSON yourself.You end up re-implementing what the SDK already does: route resolution, placeholders, field shapes, edit-mode detection.
Sitecore Content SDK (official) ✅The Sitecore-maintained TypeScript SDK that wraps Edge + Layout Service + editing.We chose this. Maintained by Sitecore, version-aligned with XM Cloud, handles every concern above.

We went with the Content SDK. The reason is simple: anything we don't use from the SDK, we'd have to write ourselves and keep in sync with Sitecore releases. That's a maintenance bill we don't want to pay.

For interactivity we use React as Astro islands — not @sitecore-content-sdk/react for runtime rendering, since Astro's component model is already framework-agnostic. The Content SDK gives us the data; Astro decides what's static HTML and what's a hydrated island.

Project setup

The stack:

  • Astro 6 with output: 'server' (SSR, not static)
  • @sitecore-content-sdk/core — Sitecore client + editing helpers
  • @astrojs/react — for interactive React islands
  • @astrojs/vercel — deployment adapter
  • Tailwind 4 — styling

astro.config.mjs:

import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel';
import react from '@astrojs/react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
  output: 'server',
  adapter: vercel(),
  integrations: [react()],
  security: {
    allowedDomains: [
      { hostname: '**.sitecorecloud.io', protocol: 'https' },
    ],
  },
  vite: {
    plugins: [tailwindcss()],
    ssr: {
      noExternal: [/^@sitecore-content-sdk\//],
    },
  },
});

 

Two non-obvious bits worth calling out:

  1. output: 'server' — we need SSR because Sitecore previews and editor mode depend on per-request query strings (sc_mode=edit, sc_itemid=...). Static output would break editing.
  2. ssr.noExternal: [/^@sitecore-content-sdk\//] — forces Vite to bundle the SDK instead of treating it as an external Node module. Without this, SSR throws on ESM/CJS interop in some environments.

Environment variables (.env):

SITECORE_EDGE_CONTEXT_ID=...
SITECORE_EDGE_CLIENT_CONTEXT_ID=...
SITECORE_EDGE_URL=https://edge-platform.sitecorecloud.io
SITECORE_DEFAULT_SITE=...
SITECORE_DEFAULT_LANGUAGE=en
SITECORE_EDITING_SECRET=...

The Sitecore client — one place, one config

src/lib/sitecore-client.ts is the single entry point. Everything that needs Sitecore data imports this client.

import { SitecoreClient } from '@sitecore-content-sdk/core/client';
import { defineConfig } from '@sitecore-content-sdk/core';
const config = defineConfig({
  api: {
    edge: {
      contextId: import.meta.env.SITECORE_EDGE_CONTEXT_ID,
      clientContextId: import.meta.env.SITECORE_EDGE_CLIENT_CONTEXT_ID,
      edgeUrl: import.meta.env.SITECORE_EDGE_URL,
    },
  },
  defaultSite: import.meta.env.SITECORE_DEFAULT_SITE,
  defaultLanguage: import.meta.env.SITECORE_DEFAULT_LANGUAGE,
  multisite: { enabled: false },
  personalize: { enabled: false },
});
export default new SitecoreClient(config);

Centralizing the client means we never re-read env vars or re-instantiate the SDK in API routes or page handlers.

Page routing — one catch-all file for every Sitecore route

This is the part that surprises people coming from Next.js: in Astro you don't need a route file per Sitecore page. One catch-all does it.

src/pages/[...path].astro:

---
import client from '../lib/sitecore-client';
import PlaceholderRenderer from '../components/PlaceholderRenderer.astro';
const rawPath = Astro.params.path ?? '';
const routePath = '/' + rawPath.split('/').filter(Boolean).join('/');
const sp = Astro.url.searchParams;
const isEditMode = sp.get('sc_mode') === 'edit';
const site = sp.get('sc_site') ?? 'sync';
const locale = sp.get('sc_lang') ?? 'en';
const page = await client.getPage(routePath, { site, locale });
if (!page) return new Response('Not found', { status: 404 });
const route = page.layout.sitecore.route;
const placeholders = route?.placeholders ?? {};
if (!isEditMode) {
  Astro.response.headers.set(
    'Cache-Control',
    'public, s-maxage=60, stale-while-revalidate=300'
  );
}
---
<!doctype html>
<html lang="en">
  <head><title>{route?.fields?.Title?.value ?? 'Page'}</title></head>
  <body>
    <header><PlaceholderRenderer renderings={placeholders['headless-header'] ?? []} /></header>
    <main><PlaceholderRenderer renderings={placeholders['headless-main'] ?? []} /></main>
    <footer><PlaceholderRenderer renderings={placeholders['headless-footer'] ?? []} /></footer>
  </body>
</html>

What's happening:

  • client.getPage(routePath, ...) calls the Layout Service through the SDK and returns the resolved route, fields, and nested placeholders.
  • Edit-mode and preview parameters from Sitecore Pages are detected via searchParams and forwarded. The same file handles published pages and the editor preview.
  • Public pages get a CDN-friendly s-maxage=60, stale-while-revalidate=300 cache header. Edit mode skips caching so editors see fresh content immediately.

Server-rendered components from placeholders

Sitecore returns a tree of "renderings" per placeholder. Each rendering has a componentName — we map those names to Astro components and render server-side.

src/components/PlaceholderRenderer.astro:

---
import HeroST from './HeroST.astro';
import HeaderST from './HeaderST.astro';
import SignupBanner from './SignupBanner.astro';
import MissingComponent from './MissingComponent.astro';
const components = { HeroST, HeaderST, SignupBanner };
const { renderings } = Astro.props;
---
{renderings.map((r) => {
  const Comp = components[r.componentName];
  return Comp
    ? <Comp fields={r.fields} params={r.params} />
    : <MissingComponent name={r.componentName} />;
})}

A few principles we hold to:

  • Default to .astro components. They render to pure HTML on the server with zero JS. Fast by default.
  • Drop into React only where you need interactivity. Mounting a React island is one line: <"headerSTReact client:load "/>. Astro takes care of the hydration boundary.
  • Unknown components don't crash the page. MissingComponent renders a visible placeholder in dev so authors can see what's missing.

React islands for interactive components

When a component genuinely needs client behavior (state, effects, a fetch) we wrap a React component in a thin .astro shell:

---
import HeaderSTReact from './HeaderST.tsx';
---
<HeaderSTReact client:load />

The .tsx file is a normal React 19 component. It can fetch('/api/sites'), use useState, etc. The static parts of the page stay JS-free; only this island ships React to the browser.

Astro API routes — direct GraphQL when you need it

Sometimes you want raw GraphQL against Sitecore Edge — e.g. a sites list for a switcher. Astro endpoints work great with the same SDK client:

// src/pages/api/sites.ts
import type { APIRoute } from 'astro';
import client from '../../lib/sitecore-client';
const SITE_QUERY = `
  query {
    site { siteInfoCollection { name hostname language } }
  }
`;
export const GET: APIRoute = async () => {
  const result = await client.getData(SITE_QUERY);
  return Response.json({ sites: result?.site?.siteInfoCollection ?? [] });
};

Same client, same env vars, no duplicated GraphQL config, and the response is consumable from any React island via fetch('/api/sites').

Wrap-up

The combination we landed on:

  • Astro for the rendering shell and routing
  • @sitecore-content-sdk/core for Sitecore data and editor integration
  • React islands for interactive bits, on top of Astro's native island model

The whole thing fits in one catch-all route, one client file, one placeholder renderer, and a handful of components. No custom GraphQL boilerplate, no parallel REST client, no getStaticPaths per content type, and previews from Sitecore Pages still work.

Next post in this series: How we handle edit mode, the Sitecore Pages preview flow, and multisite resolution.

References