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:
- Visit PageSpeed Insights
- Enter your DatoCMS-powered site URL
- Click Analyze
- Check Diagnostics > Avoid large layout shifts
- Note which elements are shifting
Using Chrome DevTools:
- Open DevTools (F12)
- Go to Performance tab
- Enable Experience > Layout Shifts
- Record page load
- Look for red Layout Shift markers
- 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
- Open DevTools → Performance
- Enable Layout Shift events
- Record page load
- 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.