Fix CLS Issues on Contentful (Layout Shift) | OpsBlu Docs

Fix CLS Issues on Contentful (Layout Shift)

Stabilize Contentful sites by sizing Image API assets with width/height params, reserving Rich Text render space, and preloading fonts.

Cumulative Layout Shift (CLS) measures visual stability of your Contentful-powered site. Layout shifts frustrate users and hurt SEO rankings.

Target: CLS under 0.1 Good: Under 0.1 | Needs Improvement: 0.1-0.25 | Poor: Over 0.25

For general CLS concepts, see the global CLS guide.

Contentful-Specific CLS Issues

1. Images Without Dimensions

The most common CLS issue is images from Contentful loading without specified dimensions.

Problem: Images push content down as they load.

Diagnosis:

  • Run PageSpeed Insights
  • Look for "Image elements do not have explicit width and height"
  • Use Chrome DevTools Performance tab to see layout shifts

Solutions:

A. Always Specify Image Dimensions

Contentful provides image dimensions in the API response:

// Extract dimensions from Contentful asset
export function ContentfulImage({ asset, alt }) {
  const { url, details } = asset.fields.file
  const { width, height } = details.image

  return (
    <img
      src={url}
      alt={alt || asset.fields.title}
      width={width}
      height={height}
      loading="lazy"
    />
  )
}

B. Use Aspect Ratio for Responsive Images

export function ResponsiveContentfulImage({ asset, alt }) {
  const { url, details } = asset.fields.file
  const { width, height } = details.image
  const aspectRatio = height / width

  return (
    <div
      style={{
        position: 'relative',
        paddingBottom: `${aspectRatio * 100}%`,
        overflow: 'hidden',
      }}
    >
      <img
        src={url}
        alt={alt || asset.fields.title}
        style={{
          position: 'absolute',
          top: 0,
          left: 0,
          width: '100%',
          height: '100%',
          objectFit: 'cover',
        }}
        loading="lazy"
      />
    </div>
  )
}

C. Modern CSS aspect-ratio

export function ModernAspectRatioImage({ asset, alt }) {
  const { url, details } = asset.fields.file
  const { width, height } = details.image

  return (
    <img
      src={url}
      alt={alt || asset.fields.title}
      width={width}
      height={height}
      style={{
        aspectRatio: `${width} / ${height}`,
        width: '100%',
        height: 'auto',
      }}
      loading="lazy"
    />
  )
}

D. Next.js Image Component

Next.js automatically prevents CLS:

import Image from 'next/image'

export function ContentfulImage({ asset, alt }) {
  const { url, details } = asset.fields.file

  return (
    <Image
      src={`https:${url}`}
      alt={alt || asset.fields.title}
      width={details.image.width}
      height={details.image.height}
      placeholder="blur"
      blurDataURL={getBlurDataURL(asset)}
    />
  )
}

E. Gatsby Image Plugin

import { GatsbyImage } from 'gatsby-plugin-image'

export function ContentfulImage({ image }) {
  return (
    <GatsbyImage
      image={image.gatsbyImageData}
      alt={image.title}
    />
  )
}

// In GraphQL query
query {
  contentfulBlogPost(slug: { eq: $slug }) {
    featuredImage {
      title
      gatsbyImageData(
        width: 1200
        placeholder: BLURRED
        formats: [AUTO, WEBP]
      )
    }
  }
}

2. Dynamic Content Loading

Content appearing after page load causes layout shifts.

Problem: Contentful content loaded client-side pushes existing content.

Diagnosis:

  • Watch for content "popping in" after page load
  • Check if skeleton/loading states match final content
  • Measure CLS during content loading

Solutions:

A. Use Skeleton Screens with Correct Dimensions

'use client'

import { useEffect, useState } from 'react'

export function BlogPost({ slug }) {
  const [post, setPost] = useState(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetchContentfulPost(slug).then(data => {
      setPost(data)
      setLoading(false)
    })
  }, [slug])

  if (loading) {
    return (
      <article>
        {/* Skeleton matches final layout exactly */}
        <div className="skeleton-title" style={{ height: '48px', marginBottom: '16px' }} />
        <div className="skeleton-image" style={{ height: '400px', marginBottom: '24px' }} />
        <div className="skeleton-text" style={{ height: '20px', marginBottom: '12px' }} />
        <div className="skeleton-text" style={{ height: '20px', marginBottom: '12px' }} />
      </article>
    )
  }

  return (
    <article>
      <h1 style={{ height: '48px', marginBottom: '16px' }}>{post.fields.title}</h1>
      <img src={post.fields.image.url} style={{ height: '400px', marginBottom: '24px' }} />
      <p>{post.fields.content}</p>
    </article>
  )
}

B. Server-Side Render Content

Avoid client-side loading entirely:

Next.js (App Router):

// app/blog/[slug]/page.tsx
async function BlogPost({ params }) {
  const post = await getContentfulPost(params.slug)

  return (
    <article>
      <h1>{post.fields.title}</h1>
      <ContentfulImage asset={post.fields.featuredImage} />
      <RichTextContent content={post.fields.body} />
    </article>
  )
}

export default BlogPost

Next.js (Pages Router):

export async function getStaticProps({ params }) {
  const post = await getContentfulPost(params.slug)

  return {
    props: { post },
    revalidate: 3600,
  }
}

export default function BlogPost({ post }) {
  return (
    <article>
      <h1>{post.fields.title}</h1>
      {/* Content already available, no loading state needed */}
    </article>
  )
}

C. Reserve Space for Dynamic Content

export function DynamicContent({ contentId }) {
  const [content, setContent] = useState(null)

  return (
    <div
      style={{
        minHeight: '500px', // Reserve space
        transition: 'min-height 0.3s ease',
      }}
    >
      {content ? (
        <ContentfulRichText content={content} />
      ) : (
        <LoadingSkeleton height="500px" />
      )}
    </div>
  )
}

3. Rich Text Rendering

Rich Text content can cause layout shifts during processing.

Problem: Rich Text content reflows as it renders.

Diagnosis:

  • Check if Rich Text blocks shift during render
  • Look for embedded content loading late
  • Profile Rich Text processing time

Solutions:

A. Pre-render Rich Text on Server

// Server-side
import { documentToHtmlString } from '@contentful/rich-text-html-renderer'

export async function getStaticProps() {
  const post = await getContentfulPost()

  const bodyHtml = documentToHtmlString(post.fields.body, {
    renderNode: {
      [BLOCKS.EMBEDDED_ASSET]: (node) => {
        const asset = node.data.target
        return `
          <img
            src="${asset.fields.file.url}"
            alt="${asset.fields.title}"
            width="${asset.fields.file.details.image.width}"
            height="${asset.fields.file.details.image.height}"
          />
        `
      },
    },
  })

  return {
    props: {
      post,
      bodyHtml,
    },
  }
}

B. Reserve Space for Embedded Content

import { documentToReactComponents } from '@contentful/rich-text-react-renderer'
import { BLOCKS } from '@contentful/rich-text-types'

const renderOptions = {
  renderNode: {
    [BLOCKS.EMBEDDED_ASSET]: (node) => {
      const asset = node.data.target
      const { width, height } = asset.fields.file.details.image

      return (
        <div
          style={{
            aspectRatio: `${width} / ${height}`,
            width: '100%',
          }}
        >
          <img
            src={asset.fields.file.url}
            alt={asset.fields.title}
            width={width}
            height={height}
            style={{ width: '100%', height: 'auto' }}
          />
        </div>
      )
    },
    [BLOCKS.EMBEDDED_ENTRY]: (node) => {
      // Reserve space for embedded entries
      return (
        <div style={{ minHeight: '200px' }}>
          <EmbeddedContent entry={node.data.target} />
        </div>
      )
    },
  },
}

export function RichTextContent({ content }) {
  return <>{documentToReactComponents(content, renderOptions)}</>
}

4. Web Fonts Loading

Custom fonts cause layout shifts if not handled correctly.

Problem: Text reflowing when web fonts load (FOUT - Flash of Unstyled Text).

Diagnosis:

  • Watch for text shifting when fonts load
  • Check for size differences between fallback and web fonts
  • Use font-display settings

Solutions:

A. Use font-display: optional

@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom-font.woff2') format('woff2');
  font-display: optional; /* Prevents layout shift */
  font-weight: 400;
}

B. Match Fallback Font Metrics

Use a fallback font that closely matches your web font:

body {
  font-family: 'CustomFont', system-ui, -apple-system, 'Segoe UI', sans-serif;
}

Or use @font-face size-adjust:

@font-face {
  font-family: 'CustomFontFallback';
  src: local('Arial');
  size-adjust: 95%; /* Match custom font size */
  ascent-override: 95%;
  descent-override: 25%;
}

body {
  font-family: 'CustomFont', 'CustomFontFallback', sans-serif;
}

C. Preload Critical Fonts

// Next.js layout
export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        <link
          rel="preload"
          href="/fonts/custom-font.woff2"
          as="font"
          type="font/woff2"
          crossOrigin="anonymous"
        />
      </head>
      <body>{children}</body>
    </html>
  )
}

5. Embedded Media from Contentful

Videos and iframes from Contentful can cause shifts.

Problem: Embedded content loading without reserved space.

Diagnosis:

  • Check for YouTube/Vimeo embeds from Contentful
  • Look for iframes without dimensions
  • Check embedded assets in Rich Text

Solutions:

A. Aspect Ratio Containers for Videos

export function EmbeddedVideo({ url, aspectRatio = 16 / 9 }) {
  return (
    <div
      style={{
        position: 'relative',
        paddingBottom: `${(1 / aspectRatio) * 100}%`,
        height: 0,
        overflow: 'hidden',
      }}
    >
      <iframe
        src={url}
        style={{
          position: 'absolute',
          top: 0,
          left: 0,
          width: '100%',
          height: '100%',
          border: 0,
        }}
        allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
        allowFullScreen
      />
    </div>
  )
}

B. Handle Embedded Entries in Rich Text

const renderOptions = {
  renderNode: {
    [BLOCKS.EMBEDDED_ENTRY]: (node) => {
      const entry = node.data.target

      // Reserve space based on content type
      if (entry.sys.contentType.sys.id === 'videoEmbed') {
        return (
          <div style={{ aspectRatio: '16 / 9' }}>
            <EmbeddedVideo url={entry.fields.url} />
          </div>
        )
      }

      if (entry.sys.contentType.sys.id === 'codeBlock') {
        return (
          <pre style={{ minHeight: '100px' }}>
            <code>{entry.fields.code}</code>
          </pre>
        )
      }

      return null
    },
  },
}

6. Dynamic Banners and Popups

Contentful-driven banners or popups can shift content.

Problem: Announcement bars or modals pushing content down.

Diagnosis:

  • Check for Contentful-driven notification banners
  • Look for popups appearing after page load
  • Measure CLS when banner appears

Solutions:

A. Reserve Space for Banners

export function AnnouncementBanner() {
  const [banner, setBanner] = useState(null)

  useEffect(() => {
    fetchContentfulBanner().then(setBanner)
  }, [])

  return (
    <div
      style={{
        height: banner ? 'auto' : '0',
        minHeight: banner ? '50px' : '0',
        overflow: 'hidden',
        transition: 'height 0.3s ease',
      }}
    >
      {banner && (
        <div style={{ padding: '16px' }}>
          {banner.fields.message}
        </div>
      )}
    </div>
  )
}

B. Use position: fixed for Overlays

export function ContentfulPopup({ popup }) {
  if (!popup) return null

  return (
    <div
      style={{
        position: 'fixed',
        top: 0,
        left: 0,
        width: '100%',
        height: '100%',
        zIndex: 1000,
        // Doesn't affect layout
      }}
    >
      <div className="popup-content">
        {popup.fields.content}
      </div>
    </div>
  )
}

7. Loading States and Transitions

Poor loading states can cause CLS.

Problem: Content appearing/disappearing causes shifts.

Diagnosis:

  • Watch transitions between loading and loaded states
  • Check if loading skeleton matches content
  • Measure CLS during transitions

Solutions:

A. Match Loading State to Content

export function BlogPostSkeleton() {
  return (
    <article>
      {/* Exact dimensions matching final content */}
      <div className="skeleton-title" style={{ height: '48px', width: '80%', marginBottom: '16px' }} />
      <div className="skeleton-meta" style={{ height: '20px', width: '40%', marginBottom: '24px' }} />
      <div className="skeleton-image" style={{ height: '400px', width: '100%', marginBottom: '24px' }} />
      <div className="skeleton-paragraph" style={{ height: '16px', width: '100%', marginBottom: '8px' }} />
      <div className="skeleton-paragraph" style={{ height: '16px', width: '95%', marginBottom: '8px' }} />
      <div className="skeleton-paragraph" style={{ height: '16px', width: '90%' }} />
    </article>
  )
}

B. Fade Transitions Instead of Layout Changes

export function ContentfulContent({ content }) {
  const [isLoading, setIsLoading] = useState(true)

  return (
    <div style={{ position: 'relative', minHeight: '500px' }}>
      <div
        style={{
          opacity: isLoading ? 1 : 0,
          transition: 'opacity 0.3s',
          position: 'absolute',
          top: 0,
          left: 0,
          width: '100%',
        }}
      >
        <LoadingSkeleton />
      </div>
      <div
        style={{
          opacity: isLoading ? 0 : 1,
          transition: 'opacity 0.3s',
        }}
      >
        {content && <RichTextContent content={content} />}
      </div>
    </div>
  )
}

Framework-Specific Solutions

Next.js

Use next/image:

import Image from 'next/image'

export function ContentfulImage({ asset }) {
  return (
    <Image
      src={`https:${asset.fields.file.url}`}
      alt={asset.fields.title}
      width={asset.fields.file.details.image.width}
      height={asset.fields.file.details.image.height}
      // Prevents CLS automatically
    />
  )
}

Streaming with Suspense:

import { Suspense } from 'react'

export default function Page() {
  return (
    <Suspense fallback={<Skeleton />}>
      <ContentfulContent />
    </Suspense>
  )
}

Gatsby

Use gatsby-plugin-image:

import { GatsbyImage } from 'gatsby-plugin-image'

export function ContentfulImage({ image }) {
  return (
    <GatsbyImage
      image={image.gatsbyImageData}
      alt={image.title}
      // Automatically prevents CLS
    />
  )
}

GraphQL for Image Data:

query {
  contentfulBlogPost {
    featuredImage {
      gatsbyImageData(
        width: 1200
        placeholder: BLURRED
      )
      title
      file {
        url
        details {
          image {
            width
            height
          }
        }
      }
    }
  }
}

React (General)

Custom Hook for Contentful Images:

export function useContentfulImage(asset: any) {
  const { url, details } = asset.fields.file
  const { width, height } = details.image

  return {
    src: url,
    width,
    height,
    aspectRatio: width / height,
  }
}

// Usage
export function ImageComponent({ asset }) {
  const image = useContentfulImage(asset)

  return (
    <img
      src={image.src}
      width={image.width}
      height={image.height}
      style={{ aspectRatio: image.aspectRatio }}
      alt={asset.fields.title}
    />
  )
}

Testing & Monitoring

Measure CLS

Chrome DevTools:

  1. Open DevTools (F12)
  2. Go to Performance tab
  3. Record page load
  4. Look for red bars labeled "Layout Shift"
  5. Click to see which elements shifted

Web Vitals Library:

import { getCLS } from 'web-vitals'

getCLS((metric) => {
  console.log('CLS:', metric.value)

  // Send to analytics
  if (window.gtag) {
    window.gtag('event', 'web_vitals', {
      event_category: 'Web Vitals',
      event_label: 'CLS',
      value: Math.round(metric.value * 1000),
      non_interaction: true,
    })
  }
})

PageSpeed Insights:

  • Test your URL at PageSpeed Insights
  • Check both Lab and Field Data
  • Review specific layout shift elements

Debug Layout Shifts

Visual Indicator:

// Highlight layout shifts (development only)
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.hadRecentInput) continue

    entry.sources.forEach(source => {
      const element = source.node
      element.style.outline = '2px solid red'
      console.log('Layout shift:', element, entry.value)
    })
  }
})

observer.observe({ type: 'layout-shift', buffered: true })

Quick Wins Checklist

Priority fixes for immediate CLS improvements:

  • Add width/height to all Contentful images
  • Use aspect-ratio CSS for responsive images
  • Match skeleton screens to final content dimensions
  • Use font-display: optional for web fonts
  • Reserve space for embedded videos/iframes
  • Pre-render Rich Text on server when possible
  • Use position: fixed for banners/popups
  • Implement proper loading states
  • Use Next.js Image or gatsby-plugin-image
  • Test with Chrome DevTools Performance tab

Advanced Optimizations

CSS contain Property

Help browser isolate layout calculations:

.contentful-content {
  contain: layout style paint;
}

Content Visibility

Improve off-screen content rendering:

.below-fold-content {
  content-visibility: auto;
  contain-intrinsic-size: 0 500px; /* Estimated height */
}

Intersection Observer for Images

Load images only when visible:

export function LazyContentfulImage({ asset }) {
  const [isVisible, setIsVisible] = useState(false)
  const imgRef = useRef(null)

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true)
          observer.disconnect()
        }
      },
      { rootMargin: '50px' }
    )

    if (imgRef.current) {
      observer.observe(imgRef.current)
    }

    return () => observer.disconnect()
  }, [])

  return (
    <div
      ref={imgRef}
      style={{
        aspectRatio: `${asset.fields.file.details.image.width} / ${asset.fields.file.details.image.height}`,
        backgroundColor: '#f0f0f0',
      }}
    >
      {isVisible && (
        <img
          src={asset.fields.file.url}
          alt={asset.fields.title}
          width={asset.fields.file.details.image.width}
          height={asset.fields.file.details.image.height}
        />
      )}
    </div>
  )
}

Next Steps

For general CLS optimization strategies, see CLS Optimization Guide.