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

Fix CLS Issues on Sanity (Layout Shift)

Stabilize Sanity-powered sites by using image hotspot dimensions, reserving Portable Text render space, and preloading frontend 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 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:

  1. Visit PageSpeed Insights
  2. Enter your Sanity-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 in settings
  4. Record page load
  5. Look for red Layout Shift markers
  6. Click markers to see affected elements

Step 2: Identify Root Causes

Sanity-Specific Issues:

  1. Sanity Image CDN: Missing width/height from image assets
  2. GROQ Query Timing: Content loads after initial render
  3. Portable Text: Rich content blocks shift during render
  4. Custom Serializers: Components load asynchronously
  5. Real-time Updates: Live preview causes content shifts

Framework Issues:

  1. SSR/SSG Hydration: Client state differs from server
  2. Dynamic Imports: Code-split components load late
  3. External Fonts: Web fonts cause text reflow
  4. 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

  1. Establish Baseline:

    • Run PageSpeed Insights 3 times
    • Average the CLS scores
    • Document shifting elements
  2. Apply Fixes

  3. Measure Improvements:

    • Clear all caches
    • Run PageSpeed Insights 3 times
    • Compare to baseline

Real User Monitoring

Monitor actual user CLS in Google Search Console:

  1. Google Search Console > Core Web Vitals
  2. Review CLS trends over 28 days
  3. Filter by device type (mobile/desktop)
  4. Check "Poor" URLs

Allow 7-14 days after fixes to see real user impact.

Chrome DevTools Performance Insights

  1. Open DevTools > Performance Insights
  2. Record page load
  3. Review Layout Shifts section
  4. See specific elements causing shifts
  5. 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

For additional performance and tracking issues, see the global issues hub:

Next Steps

Additional Resources