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 Sanity-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 Sanity Sites
Sanity Content-Related:
- Images from Sanity CDN without dimensions
- Portable Text blocks rendering asynchronously
- Dynamic content loading from GROQ queries
- Custom components in Portable 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 on Sanity Sites
Step 1: Identify Shifting Elements
Using PageSpeed Insights:
- Visit PageSpeed Insights
- Enter your Sanity-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 in settings
- Record page load
- Look for red Layout Shift markers
- Click markers to see affected elements
Step 2: Identify Root Causes
Sanity-Specific Issues:
- Sanity Image CDN: Missing width/height from image assets
- GROQ Query Timing: Content loads after initial render
- Portable Text: Rich content blocks shift during render
- Custom Serializers: Components load asynchronously
- Real-time Updates: Live preview causes content shifts
Framework Issues:
- SSR/SSG Hydration: Client state differs from server
- Dynamic Imports: Code-split components load late
- External Fonts: Web fonts cause text reflow
- Third-Party Embeds: Ads, videos, social widgets
Sanity-Specific CLS Fixes
1. Optimize Sanity Images
Sanity provides powerful image CDN features. Use them correctly to prevent shifts.
Fetch Image Dimensions with GROQ
// Query images with metadata
const query = groq`*[_type == "post" && slug.current == $slug][0]{
title,
mainImage{
asset->{
_id,
url,
metadata{
dimensions{
width,
height,
aspectRatio
}
}
},
alt
}
}`
const post = await client.fetch(query, { slug })
Next.js + Sanity Image
import Image from 'next/image'
import { urlFor } from '@/lib/sanity'
export default function PostImage({ image }) {
// Calculate dimensions from Sanity metadata
const { width, height } = image.asset.metadata.dimensions
return (
<Image
src={urlFor(image).url()}
alt={image.alt || ''}
width={width}
height={height}
// Preserve aspect ratio
style={{ width: '100%', height: 'auto' }}
// Prioritize above-fold images
priority={true}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 800px"
/>
)
}
Use next-sanity Image Component
// Install: npm install @sanity/next-loader
import { SanityImage } from '@sanity/next-loader'
export default function PostImage({ image }) {
return (
<SanityImage
image={image}
alt={image.alt || ''}
sizes="(max-width: 768px) 100vw, 800px"
// Automatically handles dimensions and optimization
/>
)
}
Gatsby + Sanity Images
// gatsby-config.js
module.exports = {
plugins: [
{
resolve: 'gatsby-source-sanity',
options: {
// ... config
watchMode: !process.env.GATSBY_IS_PREVIEW,
},
},
'gatsby-plugin-image',
],
}
// Component
import { GatsbyImage, getGatsbyImageData } from 'gatsby-plugin-image'
export default function PostImage({ image, sanityConfig }) {
const imageData = getGatsbyImageData(
image.asset,
{ width: 800 },
sanityConfig
)
return (
<GatsbyImage
image={imageData}
alt={image.alt || ''}
/>
)
}
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; /* Placeholder color */
}
.image-container img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
2. Optimize Portable Text Rendering
Portable Text can cause layout shifts if not handled properly.
Reserve Space for Blocks
import { PortableText } from '@portabletext/react'
const components = {
block: {
// Add consistent spacing
normal: ({ children }) => (
<p style={{ marginBlockEnd: '1rem', minHeight: '1.5rem' }}>
{children}
</p>
),
h2: ({ children }) => (
<h2 style={{ marginBlockEnd: '1rem', minHeight: '2rem' }}>
{children}
</h2>
),
},
types: {
image: ({ value }) => (
<div style={{ aspectRatio: value.asset?.metadata?.dimensions?.aspectRatio }}>
<img
src={urlFor(value).url()}
alt={value.alt || ''}
width={value.asset?.metadata?.dimensions?.width}
height={value.asset?.metadata?.dimensions?.height}
loading="lazy"
/>
</div>
),
},
}
export default function Article({ content }) {
return <PortableText value={content} components={components} />
}
Lazy Load Embedded Content
const components = {
types: {
youtube: ({ value }) => {
const [loaded, setLoaded] = useState(false)
return (
<div
style={{
aspectRatio: '16 / 9',
background: '#000',
position: 'relative'
}}
>
{!loaded && (
<button => setLoaded(true)}>
Load Video
</button>
)}
{loaded && (
<iframe
src={`https://www.youtube.com/embed/${value.id}`}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%'
}}
loading="lazy"
/>
)}
</div>
)
},
},
}
3. Handle GROQ 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 content = await client.fetch(query, { slug: params.slug })
return <Article content={content} />
}
Use Loading Skeletons
'use client'
import { Suspense } from 'react'
function ArticleSkeleton() {
return (
<div>
<div style={{ width: '100%', height: '400px', background: '#f0f0f0' }} />
<div style={{ width: '60%', height: '2rem', background: '#e0e0e0', marginTop: '1rem' }} />
<div style={{ width: '100%', height: '1rem', background: '#e0e0e0', marginTop: '0.5rem' }} />
<div style={{ width: '100%', height: '1rem', background: '#e0e0e0', marginTop: '0.5rem' }} />
</div>
)
}
export default function Page() {
return (
<Suspense fallback={<ArticleSkeleton />}>
<ArticleContent />
</Suspense>
)
}
Avoid Layout Shifts on Client-Side Fetching
import useSWR from 'swr'
export default function Article({ slug }) {
const { data, isLoading } = useSWR(
['article', slug],
() => client.fetch(query, { slug })
)
// Reserve space while loading
if (isLoading) {
return <ArticleSkeleton />
}
return <ArticleContent data={data} />
}
4. Prevent Hydration Mismatches
SSR/SSG can cause shifts if client state differs from server.
Match Server and Client Rendering
// Don't render client-only content during SSR
export default function Component({ content }) {
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
}, [])
return (
<div>
<div>{content.title}</div>
{/* Reserve space for client-only content */}
<div style={{ minHeight: isClient ? 'auto' : '200px' }}>
{isClient && <ClientOnlyWidget />}
</div>
</div>
)
}
Use Consistent Date/Time Formatting
// Server and client may have different timezones
import { format, parseISO } from 'date-fns'
export default function PublishDate({ publishedAt }) {
// Format consistently
const formattedDate = format(parseISO(publishedAt), 'PPP')
return <time dateTime={publishedAt}>{formattedDate}</time>
}
5. Optimize Custom Components
Custom Portable Text components can shift layouts.
Pre-define Component Dimensions
const components = {
types: {
callout: ({ value }) => (
<div
style={{
padding: '1rem',
minHeight: '100px', // Prevent shift
background: '#f0f0f0',
borderRadius: '8px'
}}
>
{value.text}
</div>
),
codeBlock: ({ value }) => (
<pre
style={{
padding: '1rem',
minHeight: '150px', // Reserve space
background: '#1e1e1e',
borderRadius: '4px',
overflow: 'auto'
}}
>
<code>{value.code}</code>
</pre>
),
},
}
6. Handle Real-Time Preview
Sanity's live preview can cause shifts.
Disable Animations in Preview
import { draftMode } from 'next/headers'
export default async function Page() {
const { isEnabled: isPreview } = draftMode()
return (
<div data-preview={isPreview}>
<style>{`
[data-preview="true"] * {
transition: none !important;
animation: none !important;
}
`}</style>
<Content />
</div>
)
}
Throttle Live Updates
import { useLiveQuery } from '@sanity/preview-kit'
import { throttle } from 'lodash'
export default function LiveArticle({ initialData, query, params }) {
const [data, setData] = useState(initialData)
const throttledUpdate = useMemo(
() => throttle((newData) => setData(newData), 1000),
[]
)
useLiveQuery(query, params, {
onUpdate: throttledUpdate
})
return <Article data={data} />
}
Framework-Specific Fixes
Next.js Image Optimization
// next.config.js
module.exports = {
images: {
domains: ['cdn.sanity.io'],
// Use modern formats
formats: ['image/avif', 'image/webp'],
// Enable responsive images
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
},
}
Font Loading Optimization
// Next.js App Router with local fonts
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Prevent FOIT
preload: true,
})
export default function RootLayout({ children }) {
return (
<html className={inter.className}>
<body>{children}</body>
</html>
)
}
CSS Container Queries
/* Reserve space for dynamic content */
.article-container {
container-type: inline-size;
min-height: 400px; /* Prevent shift while loading */
}
.article-image {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
@container (min-width: 768px) {
.article-container {
min-height: 600px;
}
}
Advanced CLS Optimization
Implement Resource Hints
// Next.js - preconnect to Sanity CDN
export default function RootLayout({ children }) {
return (
<html>
<head>
<link rel="preconnect" href="https://cdn.sanity.io" />
<link rel="dns-prefetch" href="https://cdn.sanity.io" />
</head>
<body>{children}</body>
</html>
)
}
Use Intersection Observer
// Lazy load below-fold content
import { useEffect, useRef, useState } from 'react'
export default function LazyPortableText({ content }) {
const [isVisible, setIsVisible] = useState(false)
const ref = useRef(null)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
observer.disconnect()
}
},
{ rootMargin: '200px' }
)
if (ref.current) {
observer.observe(ref.current)
}
return () => observer.disconnect()
}, [])
return (
<div ref={ref} style={{ minHeight: '500px' }}>
{isVisible ? (
<PortableText value={content} />
) : (
<div>Loading...</div>
)}
</div>
)
}
Optimize Third-Party Scripts
// Load analytics after content stabilizes
useEffect(() => {
const timer = setTimeout(() => {
// Load GTM, Meta Pixel, etc.
loadAnalytics()
}, 2000) // Delay 2 seconds
return () => clearTimeout(timer)
}, [])
Testing and Validation
Before and After Testing
Establish Baseline:
- Run PageSpeed Insights 3 times
- Average the CLS scores
- Document shifting elements
Apply Fixes
Measure Improvements:
- Clear all caches
- Run PageSpeed Insights 3 times
- Compare to baseline
Real User Monitoring
Monitor actual user CLS in Google Search Console:
- Google Search Console > Core Web Vitals
- Review CLS trends over 28 days
- Filter by device type (mobile/desktop)
- Check "Poor" URLs
Allow 7-14 days after fixes to see real user impact.
Chrome DevTools Performance Insights
- Open DevTools > Performance Insights
- Record page load
- Review Layout Shifts section
- See specific elements causing shifts
- Measure cumulative shift score
CLS Optimization Checklist
- Add width/height to all Sanity images
- Use Next.js Image or @sanity/next-loader for images
- Set aspect-ratio on image containers
- Reserve space for Portable Text blocks
- Pre-define custom component dimensions
- Fetch content server-side (SSR/SSG)
- Use loading skeletons for client-side fetches
- Prevent hydration mismatches
- Optimize font loading (font-display: swap)
- Defer third-party scripts
- Add resource hints for Sanity CDN
- Test with PageSpeed Insights
- Monitor CLS in Search Console
Expected Results
Well-optimized Sanity sites should achieve:
- Mobile CLS: 0.05 - 0.1
- Desktop CLS: 0.01 - 0.05
- Good CWV Status: 75%+ of URLs passing CLS threshold
Typical improvements from optimization:
- 60-80% CLS reduction from image optimization
- 40-60% reduction from reserved space
- 30-50% reduction from SSR/SSG content fetching
Common Issues
Issue: Next.js Image Shifts
Cause: Missing dimensions or incorrect layout prop
Solution:
<Image
src={urlFor(image).url()}
alt={image.alt}
width={image.asset.metadata.dimensions.width}
height={image.asset.metadata.dimensions.height}
style={{ width: '100%', height: 'auto' }}
/>
Issue: Portable Text Shifts
Cause: Blocks render asynchronously
Solution: Add min-height and consistent spacing
Issue: Hydration Mismatches
Cause: Server HTML differs from client React
Solution: Ensure consistent rendering, avoid client-only content in initial render
Related Issues Hub
For additional performance and tracking issues, see the global issues hub: