In my first blog post, I explored four patterns for progressive loading on XM Cloud. App Router Streaming stood out as the cleanest approach. In my second blog post, I tested the Content SDK 1.2 App Router beta with a demo project. It worked, but demos only prove concepts.
The real question: can an enterprise Sitecore project migrate from Pages Router to App Router?
I decided to find out. I created a POC branch of an existing production project and started migrating. The project was already running on Content SDK 1.1 with Pages Router and had complex navigation patterns and dozens of content components. This post covers the patterns that made migration possible and what you need to know to attempt it yourself.
App Router support is still in beta, but the migration taught me what it takes to move a real project.
The core migration pattern
The fundamental change in App Router is how components fetch data.
Pages Router used getComponentServerProps to fetch data server-side, then passed it as props to Client Components. App Router uses async Server Components that fetch their own data directly.
The pattern
Both navigation and footer had GraphQL queries in getComponentServerProps. Here's the Pages Router pattern:
import { GetComponentServerProps } from '@sitecore-content-sdk/nextjs';
import { graphQLClient } from 'lib/graphql-client';
// Pages Router: Data fetching happens in getComponentServerProps
export const getComponentServerProps: GetComponentServerProps = async (
rendering,
layoutData
) => {
const result = await graphQLClient.request<NavigationQueryResult>(
NavigationQuery,
{ rootItemId }
);
// Data passed as props to Client Component
return result;
};
const NavigationSection = (props: NavigationSectionProps) => {
// Render with props.contexts (component receives data, doesn't fetch it)
};
The App Router version moves the query into the component:
import { graphQLClient } from 'lib/graphql-client';
// App Router: Component becomes async and fetches its own data
const NavigationSection = async (props: NavigationSectionServerProps) => {
// Moved from getComponentServerProps to component itself
const result = await graphQLClient.request<NavigationQueryResult>(
NavigationQuery,
{ rootItemId }
);
// Now renders Client Component directly instead of passing as props
return <Navigation contexts={result.contexts} />;
};
Same query. Same data. Different location. The component becomes async and fetches its own data.
The AppPlaceholder requirement
The official docs say to replace Placeholder with AppPlaceholder. Placeholder is a Client Component. AppPlaceholder is a Server Component.
Components that fetch their own data become async Server Components without 'use client'. They fetch data server-side and can render Client Components that need 'use client' for interactivity (hooks, event handlers, state). Most atoms, molecules, and organisms will need 'use client' if they're interactive. Sections themselves can remain as Server Components that orchestrate everything.
How it works
While testing the Sitecore Content SDK App Router beta, I used the default behavior where components stream in. During this migration, I discovered the disableSuspense prop, which gives more control over loading behavior.
AppPlaceholder offers two approaches:
Default behavior: The SDK automatically wraps all components in <Suspense> boundaries. Each component streams in as it completes. The page shell renders immediately, then navigation, footer, and content appear progressively.
With disableSuspense: Components block the page until loaded. This is what most Sitecore developers probably want for navigation and footer. Critical UI should appear complete, not progressively.
Here's how to use it:
<AppPlaceholder
disableSuspense // Prevents automatic Suspense wrapping (blocks page until loaded)
page={page}
componentMap={componentMap}
name="project-header"
rendering={route}
params={pageContext}
/>
With disableSuspense, navigation and footer load synchronously. The page waits for them before rendering.
But you can still use progressive loading for non-critical data. Nested <Suspense> boundaries work inside components:
export default async function EnhancedNavigationSection(props: any) {
// Fetch critical navigation data (blocks page load with disableSuspense)
const result = await graphQLClient.request<NavigationQueryResult>(
NavigationQuery,
{ rootItemId }
);
return (
<>
{/* Critical UI renders synchronously */}
<Navigation contexts={result.contexts} />
{/* Progressive loading for non-critical data */}
<Suspense fallback={<div>Loading notifications...</div>}>
<AsyncNavigationData locale={locale} />
</Suspense>
</>
);
}
The async function fetches data and renders a Client Component with the results:
async function AsyncNavigationData({ locale }: { locale?: string }) {
// Fetch non-critical data that can stream in progressively
const response = await fetch(`/api/navigation/notifications?locale=${locale}`, {
cache: 'no-store', // Ensures fresh data on each request
});
const data = await response.json();
// Render Client Component with fetched data
return <NavigationNotifications data={data} />;
}
This gives precise control. Block the page for critical UI. Stream non-critical data progressively.
The tradeoff: default behavior provides faster initial render with progressive appearance. disableSuspense provides complete UI appearance with slightly slower initial render. Choose based on your UX requirements.
All the code patterns from this blog are available in this GitHub gist for easy reference.
How to approach the migration
Having migrated a real project, here's the strategy I'd recommend if starting fresh.
Start with a POC branch. Don't migrate your main branch directly. Create a separate branch to validate the approach. This reduces risk and gives you room to experiment.
Begin with a simple content page. The official Sitecore migration documentation covers the file structure changes well. Pick a page with basic components and get it rendering as Client Components. Add 'use client' to components as needed. Remove getComponentServerProps from components. You'll implement the async Server Component pattern next. This validates the core App Router structure works: file structure, routing, and AppPlaceholder rendering. Navigation and footer will render as Client Components too, though they may be missing data at this stage since you've removed getComponentServerProps but haven't implemented the async Server Component pattern yet. That's expected. You're validating structure first, data fetching second. Once this works, you know the foundation is solid.
Migrate navigation and footer next. These components appear on every page, so fixing them once fixes everywhere. This is where you start converting to async Server Components. Use the pattern from Section 1: move data fetching into the component itself and make it async. This is also when you'll discover whether disableSuspense makes sense for your navigation. Try the default streaming behavior first, then decide if blocking the page load provides better UX.
Work through remaining components systematically. Components that fetch data become async Server Components. Components with interactivity keep 'use client'. Expect to add 'use client' to most of your existing components as you migrate, especially if your project already has dozens of components. This is normal. The pattern from Section 1 applies to each one: sections fetch data as async Server Components, then render Client Components that handle interactivity.
The migration takes longer than a simple demo, but the pattern stays consistent. Start by getting pages rendering as Client Components. Then progressively convert to async Server Components where components need to fetch their own data. Once navigation and footer work with this pattern, the rest follows the same approach.
Should you migrate?
The migration is feasible for enterprise Sitecore projects. The core pattern is straightforward once you understand it: async Server Components fetch data and render Client Components for interactivity.
The main consideration is beta status. App Router support in Content SDK is still beta, which means breaking changes are possible in future releases. Assess your project's risk tolerance. POC branches are perfect for learning the pattern. New projects face a choice: start with stable Pages Router and migrate later, or adopt App Router now and accept beta risk. Both approaches are valid. Production sites should weigh the benefits against the uncertainty of beta software.
If you're planning a similar migration, I'd love to hear about your experience. What patterns worked? What challenges did you hit that I might have missed? The more real-world validation we can share as a community, the stronger our understanding becomes as this technology matures from beta to production-ready.
Update - December 15, 2025
App Router support in Content SDK has moved from beta to General Availability with the release of v1.3.1. The migration patterns covered in this post are now applicable to production-ready software.
The beta risk considerations mentioned in the conclusion no longer apply. Enterprise teams can proceed with App Router migration with full production support.