Headless Sitecore on XM Cloud lives or dies on the Layout Service contract: route.itemId, placeholder keys, componentFactory registration, and media URLs that work in preview, Edge, and production without rewriting your Next.js route map every sprint. This checklist is what I run before launch week on a JSS or SPA consumer against /sitecore/api/layout/render/jss.
Layout Service endpoint fundamentals
The standard entry point for JSS apps:
GET /sitecore/api/layout/render/jss?item={itemId}&sc_apikey={key}&sc_site={siteName}&language={lang}&mode=normal
Response shape (simplified):
{ "sitecore": { "context": { "pageEditing": false, "language": "en", "itemPath": "/home", "route": { "name": "Home", "itemId": "{GUID}", "templateId": "{GUID}", "placeholders": { "jss-main": [ /* components */ ] }, "fields": { /* route-level fields */ } } } }
}
Your frontend must treat route.itemId as canonical page identity, not URL slug alone. Slugs change; itemId survives renames if paths update correctly.
route.itemId and routing
Next.js App Router pattern with catch-all:
// app/[[...path]]/page.tsx
export default async function Page({ params }: { params: { path?: string[] } }) { const sitePath = '/' + (params.path?.join('/') ?? ''); const layout = await fetchLayoutData(sitePath, 'en'); const route = layout.sitecore.context.route; if (!route?.itemId) notFound(); return <LayoutRenderer layoutData={layout} />;
}
Maintain a route map for static generation when needed:
// route-map.ts built from Sitecore sitemap or GraphQL crawl
export const STATIC_ROUTES = [ { path: '/', itemId: '{HOME-GUID}' }, { path: '/about', itemId: '{ABOUT-GUID}' },
];
At build time, resolve itemId to path via GraphQL or Content Service. At runtime, resolve path to layout via Layout Service using item path or id depending on your fetch helper.
Placeholders and componentFactory
Every placeholder key in Sitecore must exist in your componentFactory map:
// componentFactory.ts
import Hero from '@/components/Hero';
import FAQ from '@/components/FAQ'; export const componentFactory = (componentName: string) => { const map: Record<string, React.FC> = { Hero, FAQ, ContentBlock, PromoBand, }; return map[componentName];
};
Placeholder renderer walks JSON tree:
export function Placeholder({ name, rendering }: Props) { const components = rendering.placeholders[name] ?? []; return components.map((c, i) => { const Comp = componentFactory(c.componentName); if (!Comp) { console.warn(`Unknown component: ${c.componentName}`); return null; } return <Comp key={`${c.uid}-${i}`} fields={c.fields} params={c.params} />; });
}
Unknown componentName should fail loudly in non-production and degrade gracefully in production with monitoring alert.
Media URL builder
Layout Service returns relative media paths. Centralize URL building:
const MEDIA_HOST = process.env.NEXT_PUBLIC_SITECORE_MEDIA_HOST ?? ''; export function buildMediaUrl(src: string | undefined): string { if (!src) return ''; if (src.startsWith('http')) return src; if (src.startsWith('/-/media/')) return `${MEDIA_HOST}${src}`; return `${MEDIA_HOST}/-/media/${src.replace(/^/+/, '')}`;
}
Image field shape from Layout Service:
{ "Image": { "value": { "src": "/-/media/project/corp/hero.jpg", "alt": "Hero image", "width": "1920", "height": "1080" } }
}
XM Cloud Edge may rewrite hosts. Use environment-specific media host vars; never hard-code CM hostname on public pages.
Preview vs Edge environment variables
| Variable | Preview / CM | Edge / Production |
|---|---|---|
| Layout Service base URL | CM or preview host | Edge API host |
| API key | Preview key (restricted) | Delivery key |
| GraphQL endpoint | Authoring GraphQL | Experience Edge GraphQL |
| Media host | CM media | CDN / Edge media |
Next.js example:
SITECORE_API_HOST=https://your-project.sitecorecloud.io
SITECORE_API_KEY=delivery-key-here
SITECORE_PREVIEW_SECRET=preview-secret
NEXT_PUBLIC_SITECORE_MEDIA_HOST=https://edge-media-host
Preview mode uses draft content and may set pageEditing: true. Branch preview and version preview require passing additional query params or headers per Sitecore docs for your cloud version.
CORS configuration
Browser-side Layout Service calls need CORS allowed origins on the Sitecore side (or proxy through Next.js server only). Preferred pattern: fetch layout data server-side in RSC or getServerSideProps, not from browser, to avoid exposing API keys and CORS pain.
If client-side fetch is required:
- Whitelist exact Vercel or Azure Static Web Apps origins
- Do not use wildcard with credentials
- Rotate API keys if leaked in client bundles (another reason to server-fetch)
Caching strategy
Layout Service responses are cacheable when content is published and personalization is absent.
- Next.js: use
revalidate(ISR) with webhook from Sitecore publish - CDN: cache by full URL including language and site params
- Do not cache: preview requests, editing mode, or pages with active personalization (XP)
Webhook handler sketch:
export async function POST(req: Request) { const secret = req.headers.get('x-webhook-secret'); if (secret !== process.env.SITECORE_REVALIDATE_SECRET) { return new Response('Unauthorized', { status: 401 }); } const { paths } = await req.json(); for (const p of paths) { revalidatePath(p); } return Response.json({ revalidated: paths.length });
}
Set short TTL (60 to 300 seconds) until publish webhooks are proven in staging.
GraphQL comparison
When to use Layout Service vs GraphQL:
- Layout Service: page assembly matches placeholders and component order; fastest path for JSS parity with SXA layout.
- GraphQL: granular fetches, aggregations, search-driven pages, non-page content (products, articles listing).
Many teams use Layout Service for routes and GraphQL for navigation trees or footer globals fetched once per layout shell.
query Footer($path: String!, $language: String!) { item(path: $path, language: $language) { children { results { name url { path } } } }
}
Field keys must match your TypeScript interfaces. Run schema codegen against Edge GraphQL endpoint in CI.
Next.js route map and 404 handling
404 when:
- Layout Service returns empty route or unknown item
- Item exists but
__Hiddenor not in sitemap allowed paths - Language version missing (fallback policy decides 404 vs fallback language)
export async function fetchLayoutData(path: string, lang: string) { const url = new URL('/sitecore/api/layout/render/jss', process.env.SITECORE_API_HOST); url.searchParams.set('item', path); url.searchParams.set('sc_apikey', process.env.SITECORE_API_KEY!); url.searchParams.set('sc_site', 'corp'); url.searchParams.set('language', lang); const res = await fetch(url.toString(), { next: { revalidate: 120 } }); if (res.status === 404) return null; if (!res.ok) throw new Error(`Layout fetch failed: ${res.status}`); const data = await res.json(); if (!data?.sitecore?.context?.route?.itemId) return null; return data;
}
Custom 404 page should still load global chrome (nav/footer) via separate GraphQL or cached layout fragment.
Launch runbook by day
Day minus 7: contract freeze
Freeze componentFactory names, placeholder keys, and field API names. Export Layout Service sample JSON for top 20 routes.
Day minus 5: env parity
Staging Next.js uses Edge delivery keys. Preview secrets on CM only. Verify media host on all image components.
Day minus 3: cache and webhooks
Publish test item, confirm revalidate webhook fires and page updates within SLA. Load test Layout Service P95 latency.
Day minus 1: smoke and 404
Run automated smoke on STATIC_ROUTES plus 10 random deep links. Hit intentional 404 and soft-404 URLs.
Launch day: monitoring
Watch 5xx rate on layout fetch, unknown component warnings, media 404s. Rollback via CDN cache purge and previous deployment slot.
Day plus 1: retrospective
Compare GraphQL vs Layout Service error budgets. Ticket any componentName drift between CM and JS repo.
Monitoring and alerts
- Layout Service latency P95 and error rate by route
- Ratio of null componentFactory lookups
- Media URL 404 count from CDN logs
- Cache hit ratio on layout fetch layer
- Preview vs delivery key misuse (401 spikes)
Structured log example:
{ "event": "layout_fetch", "path": "/products/widget", "itemId": "{GUID}", "status": 200, "durationMs": 142, "componentCount": 8, "environment": "edge"
}
Security notes
Never embed delivery API keys in client bundles for unrestricted queries. Scope keys to read-only delivery. Rotate on engineer offboarding. Separate preview keys with shorter TTL and IP restrictions where supported.
Common launch defects
- Placeholder renamed in Sitecore, not in React Placeholder components
- componentName mismatch (Sitecore “Hero Banner” vs factory “Hero”)
- Language fallback returns wrong route; SEO hreflang broken
- Media URLs point at CM hostname blocked by CSP on public site
- ISR cache serves stale content after publish because webhook path mismatch
Editing and preview mode query params
Layout Service accepts mode flags that change response shape. Normal delivery omits editing chrome. Preview and editing modes may include additional fields for inline editors. Your fetch helper must branch:
function layoutUrl(path: string, lang: string, preview: boolean) { const url = new URL('/sitecore/api/layout/render/jss', base); url.searchParams.set('item', path); url.searchParams.set('language', lang); if (preview) { url.searchParams.set('sc_mode', 'preview'); } return url.toString();
}
Never cache preview responses at CDN. Tag responses with Cache-Control: private when pageEditing is true.
componentFactory and TypeScript strictness
Define a discriminated union for known components:
type HeroFields = { Title: Field<string>; Subtitle: Field<string> };
type HeroRendering = { componentName: 'Hero'; fields: HeroFields }; type SitecoreComponent = HeroRendering | FAQRendering | ContentBlockRendering;
Exhaustive switch in factory catches renames at compile time when componentName constants are shared from a generated manifest.
Generating component manifest from Sitecore
Run a CM script or GraphQL crawl to emit sitecore-manifest.json:
{ "components": [ { "name": "Hero", "templateId": "{GUID}", "placeholders": ["corp-main"] } ]
}
CI diff fails PR when Sitecore adds rendering but manifest not updated. Closes the loop between back-end authors and front-end repo.
Personalization and headless (XP note)
If XP personalization rules target renderings, Layout Service responses vary by visitor. Cache keys must include persona or rule identifiers, or disable cache on affected routes. XM-only sites skip this section. Mixed XP headless projects should document which routes are uncacheable in the route map.
Edge middleware and geo routing
XM Cloud Edge may route to regional endpoints. Store API host in env var per deployment region. Latency tests from US and EU on staging before launch. Media CDN should align with Edge region to avoid cross-region image fetches.
404 vs soft 404 for archived content
Retired products may return 200 with empty main placeholder if item exists but all renderings removed. Product decision: true 404 vs redirect to category. Implement redirect rules in Sitecore or Next.js middleware based on Retired checkbox on route item field.
Load testing Layout Service
Simulate launch traffic against Edge with k6 or Azure Load Testing:
export default function () { const res = http.get(`${EDGE_HOST}/sitecore/api/layout/render/jss?item=/home&sc_apikey=${KEY}&sc_site=corp&language=en`); check(res, { 'status 200': (r) => r.status === 200 }); check(res, { 'has route': (r) => JSON.parse(r.body).sitecore.context.route.itemId });
}
Target P95 under 500ms for home route at expected RPS. Scale Next.js replicas before blaming Sitecore if server-side fetch is the bottleneck.
Local development against cloud CM
Developers fetch layout from shared dev Edge, not production. Use .env.local with dev keys. VPN or IP allow list may block coffee shop dev; document tunnel or proxy. Never commit API keys; pre-commit hook scan for sc_apikey= patterns.
Structured data and route-level fields
JSON-LD often lives in route fields, not components. Fetch route.fields in layout shell and inject in <head>. Validate with Google Rich Results test in staging. Layout Service returns route fields separately from placeholder components; do not assume SEO component is always present.
Multi-site and sc_site parameter
Multi-site XM installs require correct sc_site on every layout fetch. Wrong site returns empty placeholders or home page content on subsidiary paths. Centralize site name in env config; integration tests iterate all public sites with three routes each.
Site definition in Sitecore must list hostnames matching Vercel or Azure front door routes. Hostname mapping errors show up as intermittent 404 on one brand only.
Versioning Layout Service contract in OpenAPI
Document expected JSON schema for top components in OpenAPI or JSON Schema stored in repo. Validate staging responses in CI against schema on every deploy. Breaking field renames in Sitecore without schema update fail build before authors notice in preview.
Disaster recovery for delivery keys
Store API key rotation procedure in runbook: generate new Edge key, dual-write in Next.js env for one release, revoke old key after traffic confirms new key. Layout Service 401 spikes during rotation mean a slot still holds stale env. Monitor error rate per deployment slot during rotation window.
Actionable checklist
- Confirm Layout Service URL, site name, and delivery API key per environment.
- Register every rendering componentName in componentFactory with tests.
- Align placeholder keys between Sitecore layout and React Placeholder usage.
- Centralize buildMediaUrl with environment-specific media host.
- Fetch layout server-side; avoid exposing API keys in browser.
- Configure ISR revalidate and publish webhook with shared secret.
- Export route map with itemId for static paths and smoke tests.
- Implement 404 when route.itemId is missing or item is hidden.
- Run day minus 7 through launch day runbook with monitoring dashboards live.
- Document Layout Service vs GraphQL split for globals, lists, and page bodies.