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

Fix CLS Issues on Datocms (Layout Shift)

Stabilize DatoCMS sites by using responsive image fields with blur-up placeholders, reserving structured text space, and preloading fonts.

Cumulative Layout Shift (CLS) measures visual stability during page load. Poor CLS scores harm user experience and SEO rankings. This guide addresses CLS issues specific to DatoCMS-powered sites.

Understanding CLS

What is CLS?

CLS measures unexpected layout shifts that occur during the entire lifespan of a page.

Google's CLS Thresholds:

  • Good: Less than 0.1
  • Needs Improvement: 0.1 - 0.25
  • Poor: Greater than 0.25

Common CLS Causes on DatoCMS Sites

DatoCMS Content-Related:

  • Images from DatoCMS/Imgix without dimensions
  • Modular content blocks rendering asynchronously
  • Dynamic content loading from GraphQL queries
  • Custom blocks in structured text
  • Real-time preview updates

Framework-Related:

  • Client-side hydration shifts
  • Next.js Image component misconfiguration
  • Lazy-loaded components
  • Font loading (FOUT/FOIT)
  • Third-party scripts and embeds

Diagnosing CLS Issues

Step 1: Identify Shifting Elements

Using PageSpeed Insights:

  1. Visit PageSpeed Insights
  2. Enter your DatoCMS-powered site URL
  3. Click Analyze
  4. Check Diagnostics > Avoid large layout shifts
  5. Note which elements are shifting

Using Chrome DevTools:

  1. Open DevTools (F12)
  2. Go to Performance tab
  3. Enable Experience > Layout Shifts
  4. Record page load
  5. Look for red Layout Shift markers
  6. Click markers to see affected elements

DatoCMS-Specific CLS Fixes

1. Optimize DatoCMS/Imgix Images

DatoCMS provides powerful image optimization via Imgix. Use dimensions correctly to prevent shifts.

Fetch Image Dimensions with GraphQL

query BlogPost($slug: String!) {
  blogPost(filter: { slug: { eq: $slug } }) {
    title
    coverImage {
      url
      width
      height
      alt
      responsiveImage(
        imgixParams: { fit: crop, w: 1200, h: 800, auto: format }
      ) {
        src
        srcSet
        webpSrcSet
        width
        height
        aspectRatio
        alt
        base64
      }
    }
  }
}

Next.js + DatoCMS Image

import Image from 'next/image'

export function PostImage({ coverImage }) {
  const { responsiveImage } = coverImage

  return (
    <Image
      src={responsiveImage.src}
      alt={responsiveImage.alt || ''}
      width={responsiveImage.width}
      height={responsiveImage.height}
      style={{ width: '100%', height: 'auto' }}
      priority={true}
      placeholder="blur"
      blurDataURL={responsiveImage.base64}
    />
  )
}

Use react-datocms Image Component

import { Image } from 'react-datocms'

export function BlogHero({ coverImage }) {
  return (
    <Image
      data={coverImage.responsiveImage}
      pictureStyle={{
        width: '100%',
        height: 'auto',
        objectFit: 'cover'
      }}
    />
  )
}

Set Aspect Ratio Containers

/* Reserve space before image loads */
.image-container {
  position: relative;
  aspect-ratio: 16 / 9; /* Use actual image ratio */
  width: 100%;
  background: #f0f0f0;
}

.image-container img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

Using the aspect ratio from DatoCMS:

export function ImageContainer({ coverImage }) {
  const { responsiveImage } = coverImage

  return (
    <div style={{ aspectRatio: responsiveImage.aspectRatio }}>
      <img
        src={responsiveImage.src}
        srcSet={responsiveImage.srcSet}
        alt={responsiveImage.alt}
        width={responsiveImage.width}
        height={responsiveImage.height}
        loading="lazy"
      />
    </div>
  )
}

2. Optimize Modular Content Rendering

DatoCMS modular content can cause layout shifts if not handled properly.

Reserve Space for Blocks

import { StructuredText, renderNodeRule } from 'react-datocms'
import { isHeading, isParagraph } from 'datocms-structured-text-utils'

export function ModularContent({ content }) {
  return (
    <StructuredText
      data={content}
      customNodeRules={[
        renderNodeRule(isHeading, ({ node, children, key }) => {
          const HeadingTag = `h${node.level}` as any
          return (
            <HeadingTag key={key} style={{ minHeight: '2rem' }}>
              {children}
            </HeadingTag>
          )
        }),
        renderNodeRule(isParagraph, ({ children, key }) => (
          <p key={key} style={{ minHeight: '1.5rem' }}>
            {children}
          </p>
        )),
      ]}
      renderBlock={({ record }) => {
        switch (record.__typename) {
          case 'ImageBlockRecord':
            return (
              <div style={{ aspectRatio: record.image.width / record.image.height }}>
                <img
                  src={record.image.url}
                  alt={record.image.alt}
                  width={record.image.width}
                  height={record.image.height}
                />
              </div>
            )
          case 'VideoBlockRecord':
            return (
              <div style={{ aspectRatio: '16/9', background: '#000' }}>
                {/* Video player */}
              </div>
            )
          default:
            return null
        }
      }}
    />
  )
}

Lazy Load Embedded Content

import { Suspense, lazy } from 'react'

const VideoEmbed = lazy(() => import('./VideoEmbed'))

export function StructuredContent({ content }) {
  return (
    <StructuredText
      data={content}
      renderBlock={({ record }) => {
        if (record.__typename === 'VideoBlockRecord') {
          return (
            <div style={{ aspectRatio: '16/9', minHeight: '400px' }}>
              <Suspense fallback={<div>Loading video...</div>}>
                <VideoEmbed data={record} />
              </Suspense>
            </div>
          )
        }
        return null
      }}
    />
  )
}

3. Handle GraphQL Query Loading States

Prevent shifts during content loading.

Server-Side Data Fetching

// Next.js App Router
export default async function Page({ params }) {
  // Fetch content server-side to avoid client shifts
  const { data } = await fetchDatoCMS(`
    query {
      blogPost(filter: { slug: { eq: "${params.slug}" } }) {
        title
        content {
          value
          blocks {
            __typename
            ... on ImageBlockRecord {
              id
              image { width, height, url, alt }
            }
          }
        }
      }
    }
  `)

  return <Article post={data.blogPost} />
}

Skeleton Screens for CSR

If you must use client-side rendering:

'use client'

import { useEffect, useState } from 'react'

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

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

  if (loading) {
    return <PostSkeleton /> // Maintains layout
  }

  return <Article post={post} />
}

function PostSkeleton() {
  return (
    <div>
      <div style={{ width: '100%', aspectRatio: '16/9', background: '#f0f0f0' }} />
      <div style={{ height: '3rem', background: '#f0f0f0', margin: '1rem 0' }} />
      <div style={{ height: '1.5rem', background: '#f0f0f0', margin: '0.5rem 0' }} />
      <div style={{ height: '1.5rem', background: '#f0f0f0', margin: '0.5rem 0' }} />
    </div>
  )
}

4. Font Loading Optimization

Preload Fonts

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

Use font-display: swap

@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/font.woff2') format('woff2');
  font-display: swap; /* Prevents invisible text */
}

Use Next.js Font Optimization

import { Inter } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
})

export default function RootLayout({ children }) {
  return (
    <html className={inter.variable}>
      <body>{children}</body>
    </html>
  )
}

5. Third-Party Embeds

Reserve space for third-party content from DatoCMS:

export function ThirdPartyEmbed({ embedCode, aspectRatio = '16/9' }) {
  return (
    <div
      style={{
        position: 'relative',
        aspectRatio: aspectRatio,
        width: '100%',
      }}
    >
      <div dangerouslySetInnerHTML={{ __html: embedCode }} />
    </div>
  )
}

6. Dynamic Content Injection

Avoid injecting content that shifts layout:

// Bad - causes shift
export function DynamicContent() {
  const [banner, setBanner] = useState(null)

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

  return (
    <>
      {banner && <Banner data={banner} />}
      <MainContent />
    </>
  )
}

// Good - reserves space
export function DynamicContent() {
  const [banner, setBanner] = useState(null)

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

  return (
    <>
      <div style={{ minHeight: banner ? 'auto' : '100px' }}>
        {banner ? <Banner data={banner} /> : <BannerSkeleton />}
      </div>
      <MainContent />
    </>
  )
}

Testing CLS

Chrome DevTools

  1. Open DevTools → Performance
  2. Enable Layout Shift events
  3. Record page load
  4. Analyze layout shift markers

Web Vitals Library

import { onCLS } from 'web-vitals'

onCLS((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),
    })
  }
})

Quick Wins Checklist

  • Add width/height to all DatoCMS images
  • Use responsiveImage from DatoCMS GraphQL
  • Set aspect-ratio on image containers
  • Reserve space for modular content blocks
  • Use blur placeholders from DatoCMS
  • Preload critical fonts
  • Use font-display: swap
  • Add skeleton screens for loading states
  • Reserve space for third-party embeds
  • Test with Chrome DevTools Performance tab
  • Monitor CLS with Web Vitals

Next Steps

For general CLS optimization, see CLS Guide.