In my previous blog post, I explored four patterns for progressive loading on XM Cloud. App Router Streaming stood out as the cleanest approach. The only problem: Sitecore didn't support it yet.
Shortly after publishing, Sitecore released Content SDK 1.2 with App Router beta support.
I tested it with a local container. This post covers the setup, the beta limitations, and App Router working with real Sitecore components.
Getting started
The xmcloud-starter-js repository works differently than XM-Cloud-Introduction. Content isn't automatically seeded. You create it manually. I used the App Router beta branch from the repository.
I opened Content Editor at https://xmcloudcm.localhost/sitecore/, created a Site Collection, then added a new Site called "SkatePark". During creation, I checked the "Basic site" module, which installed the SXA components and sample content. For the Next.js app, I used the kit-nextjs-skate-park starter, created .env.local with local container credentials and ran the bootstrap commands to generate the component map.
The dev server started, but the page showed an error.
The middleware issue
Here's what showed up:
GraphQL client misconfigured.
Configure one of the following in sitecore.config or your .env file:
Edge mode: set both sitecore.edge.contextId (server-side) and sitecore.edge.clientContextId (browser).
Local API mode: set api.local.apiHost and api.local.apiKey.
I traced through the SDK source code and found the issue. The middleware components are configured for Edge-only mode and don't support local containers.
The fix: conditionally construct middlewares in src/middleware.ts:
const isEdgeMode = !!(
scConfig.api.edge?.contextId ||
scConfig.api.edge?.clientContextId
);
const middlewareChain = isEdgeMode
? [locale, multisite, redirects, personalize]
: [locale]; // Local container: only locale middleware
export function middleware(req: NextRequest, ev: NextFetchEvent) {
return defineMiddleware(...middlewareChain).exec(req, ev);
}
This prevents the Edge-dependent middlewares from initializing in local mode. The site loaded.
The demo: async streaming in action
With the site working, I enhanced the Promo component to demonstrate async streaming. The goal: show inventory checking that doesn't block the page load.
Here's the original component:
export const Default = (props: PromoProps): JSX.Element => {
const renderText = (fields: Fields) => (
<>
<div className="field-promotext">
<ContentSdkRichText field={fields.PromoText} />
</div>
<div className="field-promolink">
<ContentSdkLink field={fields.PromoLink} />
</div>
</>
);
return <PromoContent {...props} renderText={renderText} />;
};
And here's the enhanced version with streaming:
async function AsyncPromoData({ productId }: { productId: number }) {
const response = await fetch(`http://localhost:3000/api/products/availability?id=${productId}`, {
cache: 'no-store',
});
const data = await response.json();
return (
<div>
<strong>In Stock:</strong> {data.quantity} items
</div>
);
}
export const Default = async (props: PromoProps): Promise<JSX.Element> => {
// Delay to make streaming visible (remove in production)
await new Promise(resolve => setTimeout(resolve, 2000));
const renderText = (fields: Fields) => (
<>
<div className="field-promotext">
<ContentSdkRichText field={fields.PromoText} />
</div>
<div className="field-promolink">
<ContentSdkLink field={fields.PromoLink} />
</div>
<Suspense fallback={<p>Loading...</p>}>
<AsyncPromoData productId={Math.random()} />
</Suspense>
</>
);
return <PromoContent {...props} renderText={renderText} />;
};
The API endpoint simulates a slow inventory check:
export async function GET(request: NextRequest) {
// Simulate slow external API (2-second delay for demo)
await new Promise((resolve) => setTimeout(resolve, 2000));
const data = {
quantity: Math.floor(Math.random() * 50) + 10,
};
return NextResponse.json(data);
}
You can find the complete code in this Gist.
Three additions make it work: async keyword on the component, await fetch() for the API call, and <Suspense> boundary for progressive loading. The Sitecore content renders immediately. Stock availability streams in without blocking.
The demo shows two loading phases with simulated delays.
How it works
The SDK automatically adds loading states to every component. When a component renders, it shows a loading message until complete. Currently, the data comes from a single GraphQL query in page.tsx that fetches everything at once. The SDK's comment "Likely will be deprecated" on the getComponentData call suggests future versions might support true component-level fetching, making each component independently stream its own data.
My custom <Suspense> in the Promo adds a second layer. The first handles component rendering, the second handles the API fetch. This creates two-phase streaming: Sitecore content appears first, then inventory data streams in progressively.
Pages Router vs App Router
Both fetch Sitecore data the same way: one GraphQL query at the page level, props passed to components. The difference is in adding progressive loading for additional data like inventory checks or pricing.
| Feature | Pages Router | App Router |
|---|---|---|
| Data Fetching | getServerSideProps, getStaticProps |
Async component-based fetching |
| Progressive Loading | Manual state management | Server streaming with Suspense |
| Local Container | Works | Needs middleware fix |
| Status | Stable | Beta |
The difference is dramatic. Pages Router requires the Conditional SSR pattern with navigation detection, state hooks, and manual loading management. App Router handles it natively.
Conclusion
App Router streaming works today with Sitecore. The beta has limitations. Edge-only middlewares need conditional construction for local containers. But the fix is straightforward and the code is dramatically cleaner than Pages Router alternatives.
The Hybrid Placeholder concept remains the same: don't make users wait for slow operations. App Router just provides native primitives to achieve it. When the beta stabilizes, this will be the cleanest path forward for progressive loading on XM Cloud.
Test it yourself with the middleware fix. I'm curious to hear what others discover as the beta evolves.
