Progressive loading on XM Cloud: beyond the Hybrid Placeholder

In 2021, I created the Hybrid Placeholder for Sitecore JSS to solve a performance problem: when Rendering Contents Resolvers are slow, they block the entire page load. The solution was to split the resolver into fast and slow parts. Fast content renders immediately, heavy operations load progressively.

Then came XM Cloud.

The XM Cloud Challenge

XM Cloud is Sitecore's SaaS platform. While it brings many benefits, the architecture shifts away from custom .NET code on the Sitecore backend. The Hybrid Placeholder relies on HybridRenderingContentsResolver, a custom C# base class. For XM Cloud, the recommended approach is to use headless patterns without custom Rendering Contents Resolvers.

The question became: can we maintain the progressive loading UX using these recommended patterns?

Finding the Solution

I built a POC to explore different Next.js patterns that could replace the Hybrid Placeholder while maintaining the same user experience. After evaluating multiple approaches, I found four viable solutions, each with different tradeoffs.

In this blog, I'll show you the patterns that work, the code behind them, and when to use each approach. The future looks promising with Next.js App Router support coming to Sitecore Content SDK soon.

The Four Patterns

After testing different approaches, four patterns emerged as viable solutions for XM Cloud migration.

1. Client-Only Fetching

The simplest approach. All data fetching happens client-side using useState and useEffect. This works on XM Cloud today, but data isn't in the initial HTML, so there's no SEO benefit. Best suited for user-specific content like personalized dashboards or account information.

2. Blocking SSR

Traditional server-side rendering with getServerSideProps. The server fetches all data before sending HTML to the browser. This provides full SEO support, but the downside is slower Time to First Byte (TTFB) since every request waits for data. It also blocks on client-side navigation, which defeats the purpose of a SPA.

3. Conditional SSR

This is where it gets interesting. Smart server-side rendering that detects the navigation type. On direct page loads (typing URL or refresh), it fetches data server-side for SEO. On client-side navigation (clicking links), it skips SSR and fetches client-side for speed. This maintains the progressive loading UX from the original Hybrid Placeholder.

4. App Router Streaming

The future of Next.js. Async Server Components with Suspense streaming. This provides the best performance and the cleanest code, but Sitecore doesn't support App Router yet. When support arrives, this will be the ideal solution.

Let's look at each pattern in detail.

Pattern 1: Client-Only Fetching

This is the most straightforward migration. No SSR, just client-side data fetching.

const Component = ({ title, text }) => {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(result => {
        setData(result);
        setIsLoading(false);
      });
  }, []);

  return (
    <div>
      <h3>{title}</h3>
      {isLoading && <p>Loading...</p>}
      {!isLoading && <p>{data.date}</p>}
      <div dangerouslySetInnerHTML={{__html: text}} />
    </div>
  );
};

This pattern works on XM Cloud today. The component always shows a loading state first, then displays data once fetched. Simple to implement, but data isn't in the HTML, which means no SEO.

Pattern 2: Blocking SSR

Traditional server-side rendering. Data is fetched before HTML is sent to the browser.

export const getServerSideProps = async () => {
  const response = await fetch('/api/data');
  const data = await response.json();
  return { props: { data } };
};

const Component = ({ data, title, text }) => {
  return (
    <div>
      <h3>{title}</h3>
      <p>{data.date}</p>
      <div dangerouslySetInnerHTML={{__html: text}} />
    </div>
  );
};

Data is in the HTML (good for SEO), but this pattern blocks on every request. Even when users navigate within the SPA, the server still waits for all data before responding. This slows down navigation and increases server load.

Pattern 3: Conditional SSR

This pattern replicates the Hybrid Placeholder behavior using Next.js features. It detects whether the user loaded the page directly or navigated via client-side routing, then chooses the appropriate strategy.

// Helper to detect navigation type
export function isClientNavigation(context) {
  return !!context.req?.headers['x-nextjs-data'];
}

// Server-side logic
export const getServerSideProps = async (context) => {
  // Skip SSR on client navigation
  if (isClientNavigation(context)) {
    return { props: { data: null } };
  }

  // Fetch server-side on direct load
  const response = await fetch('/api/data');
  const data = await response.json();
  return { props: { data } };
};

// Component
const Component = ({ data: initialData, title, text }) => {
  const [data, setData] = useState(initialData);
  const [isLoading, setIsLoading] = useState(initialData === null);

  useEffect(() => {
    if (!data) {
      fetch('/api/data')
        .then(res => res.json())
        .then(result => {
          setData(result);
          setIsLoading(false);
        });
    }
  }, [data]);

  return (
    <div>
      <h3>{title}</h3>
      {isLoading && <p>Loading...</p>}
      {!isLoading && <p>{data.date}</p>}
      <div dangerouslySetInnerHTML={{__html: text}} />
    </div>
  );
};

This achieves the same UX as the original Hybrid Placeholder. Direct page loads get full SEO (data in HTML), client-side navigation stays fast (skips SSR). It works on XM Cloud today without custom .NET backend code.

Pattern 4: App Router Streaming

The future of Next.js. With App Router, you can use Async Server Components that stream HTML progressively.

// Async Server Component
async function DataComponent({ title, text }) {
  const response = await fetch('/api/data');
  const data = await response.json();

  return (
    <div>
      <h3>{title}</h3>
      <p>{data.date}</p>
      <div dangerouslySetInnerHTML={{__html: text}} />
    </div>
  );
}

// Page component
export default function Page() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <DataComponent title="Example" text="<p>Content</p>" />
    </Suspense>
  );
}

The browser receives HTML in chunks. Static content appears immediately, async data streams when ready. No useState, no useEffect, just clean async/await. The framework handles everything.

Sitecore doesn't support App Router yet, but support is expected soon. When it arrives, this will be the cleanest migration path.

Pattern Comparison

Here's how the patterns compare:

Pattern SEO TTFB XM Cloud Complexity
Client-Only No Fast Yes Low
Blocking SSR Yes Slow Yes Low
Conditional SSR Yes Variable Yes Medium
App Router Yes Fast Future Low

You can also find all examples in this Gist.

Which Pattern to Use?

For user-specific content: Use Client-Only. Personalized data doesn't need SEO, and this pattern is simple to implement.

For SEO-critical content: Use Conditional SSR. It balances SEO on direct loads with fast navigation on client-side routing.

For the future: App Router Streaming will be the best option when Sitecore adds support. It provides the cleanest code and best performance.

Conclusion

The Hybrid Placeholder solved a real problem in 2021, but XM Cloud requires a different approach. The good news is that modern Next.js provides native patterns that achieve the same progressive loading UX.

For immediate XM Cloud migration, Conditional SSR provides the best balance. It works today, supports SEO, and maintains fast navigation. When Sitecore announces App Router support, migrating to that pattern will simplify the code even further.

The core idea remains the same: don't make users wait for slow operations. The implementation just adapts to the platform.