Fix Bloomreach Experience Manager CLS Issues | OpsBlu Docs

Fix Bloomreach Experience Manager CLS Issues

Stabilize Bloomreach layouts by reserving space for personalized components, preloading fonts, and sizing SPA-rendered content.

General Guide: See Global CLS Guide for universal concepts and fixes.

What is CLS?

Cumulative Layout Shift measures visual stability. Google recommends CLS under 0.1. Bloomreach Experience Manager sites suffer CLS primarily from client-side personalization swaps, SPA component hydration, and dynamically-loaded experience components.

Bloomreach-Specific CLS Causes

  • Personalization content swaps -- Bloomreach Engagement replaces default content with personalized variants after the page renders, causing visible shifts
  • SPA hydration layout changes -- React/Next.js hydration can change element dimensions when JavaScript takes over from server-rendered HTML
  • Experience Manager components -- components fetched via the Delivery API render at unknown heights until their data arrives
  • A/B test variant loading -- Bloomreach experiments swap DOM elements client-side, shifting surrounding content
  • Lazy-loaded component containers -- brXM containers that load additional components on scroll without reserved space

Fixes

1. Reserve Space for Personalization Slots

Bloomreach Engagement personalizes content by replacing DOM nodes. Reserve space to prevent shifts:

/* Reserve space for personalization containers */
[data-brx-container] {
  contain: layout;
}

/* Hero personalization slot -- use your known hero dimensions */
.hero-personalization {
  min-height: 500px;
  aspect-ratio: 16 / 9;
  background: #f0f0f0; /* Placeholder while variant loads */
}

/* Product recommendation carousel */
.recommendation-slot {
  min-height: 350px;
  contain: layout style;
}

Better approach -- use server-side personalization to avoid client-side swaps entirely:

// Server-side personalization via Delivery API
export async function getServerSideProps(context) {
  const segmentId = getSegmentFromCookie(context.req);

  const pageModel = await fetch(
    `${BRXM_ENDPOINT}/pages/home?segment=${segmentId}`,
    { headers: { 'X-Visitor-Segment': segmentId } }
  );

  // Content is already personalized -- no client-side swap needed
  return { props: { pageModel: await pageModel.json() } };
}

2. Stabilize SPA Hydration

Prevent React hydration from changing layout dimensions:

// Use CSS containment on component wrappers
function BrComponent({ component, page }) {
  const data = component.getModels();

  return (
    <div
      style={{
        contain: 'layout',
        minHeight: getMinHeight(component.getName()),
      }}
    >
      <ComponentRenderer data={data} page={page} />
    </div>
  );
}

// Map component types to known minimum heights
function getMinHeight(componentName) {
  const heights = {
    'hero-banner': '500px',
    'product-grid': '600px',
    'content-block': '200px',
    'newsletter-signup': '150px',
  };
  return heights[componentName] || '100px';
}

3. Handle Component Container Loading

brXM containers load child components dynamically. Use skeleton placeholders:

// Skeleton loader for Bloomreach containers
function ContainerWithSkeleton({ container, page }) {
  const [isLoaded, setIsLoaded] = useState(false);
  const components = container.getChildren();

  return (
    <div className="brxm-container" style={{ contain: 'layout' }}>
      {!isLoaded && (
        <div className="skeleton-loader" style={{ minHeight: '300px' }}>
          <div className="skeleton-block" />
          <div className="skeleton-block" />
        </div>
      )}
      {components.map((component) => (
        <BrComponent
          key={component.getId()}
          component={component}
          page={page} => setIsLoaded(true)}
        />
      ))}
    </div>
  );
}
.skeleton-loader {
  animation: pulse 1.5s ease-in-out infinite;
}

.skeleton-block {
  height: 120px;
  margin-bottom: 16px;
  background: #e0e0e0;
  border-radius: 4px;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.5; }
}

4. Fix A/B Test Variant Swaps

Bloomreach experiments swap content client-side. Reduce CLS by:

// Hide content until experiment variant is determined
// Add to your global CSS
.experiment-pending {
  opacity: 0;
  transition: opacity 0.2s ease;
}

.experiment-resolved {
  opacity: 1;
}

// In your experiment wrapper component
function ExperimentSlot({ experimentId, defaultContent, variants }) {
  const [variant, setVariant] = useState(null);
  const containerRef = useRef(null);

  useEffect(() => {
    // Bloomreach Engagement SDK callback
    exponea.getVariant(experimentId, (result) => {
      setVariant(result.variant);
    });
  }, [experimentId]);

  return (
    <div
      ref={containerRef}
      className={variant ? 'experiment-resolved' : 'experiment-pending'}
      style={{ minHeight: '200px', contain: 'layout' }}
    >
      {variant ? variants[variant] : defaultContent}
    </div>
  );
}

5. Preload Fonts Used in Components

Bloomreach themes often load fonts per-component rather than globally:

<!-- Preload in document head -->
<link rel="preload" href="/fonts/brand-font.woff2" as="font" type="font/woff2" crossorigin />
@font-face {
  font-family: 'BrandFont';
  src: url('/fonts/brand-font.woff2') format('woff2');
  font-display: swap;
  size-adjust: 102%;
}

Measuring CLS on Bloomreach

  1. Chrome DevTools Performance tab -- record page load and look for layout-shift entries. The "Sources" column shows which elements shifted.
  2. Compare SSR vs. CSR -- disable JavaScript to see the server-rendered layout, then re-enable to see what changes during hydration
  3. Test personalized pages -- use Bloomreach's preview mode to test different audience segments and measure CLS for each variant
  4. Web Vitals API -- instrument your frontend for real-user CLS monitoring:
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      // Send to your analytics
      trackCLS(entry.value, entry.sources?.[0]?.node?.className);
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

Analytics Script Impact

  • Bloomreach Engagement SDK -- the primary CLS risk; it triggers personalization swaps and experiment variants client-side. Move to server-side decisioning when possible.
  • Tag managers -- GTM or Tealium loading additional scripts that inject DOM elements (chat widgets, notification bars) cause CLS. Use position: fixed for any injected UI.
  • Consent managers -- cookie banners pushed by OneTrust or Cookiebot should overlay, not push, page content.