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

Fix CLS Issues on Strapi (Layout Shift)

Stabilize Strapi-powered sites by using media library dimensions in API responses, reserving content-type render space, and preloading fonts.

Cumulative Layout Shift (CLS) measures visual stability - how much unexpected movement occurs as your Strapi-powered site loads. Since Strapi is headless, CLS issues often stem from how your frontend framework handles API data and renders content.

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.

Strapi-Specific CLS Issues

1. Images from Strapi Without Dimensions

The most common CLS issue on Strapi sites: images loaded from the media library without explicit dimensions.

Problem: Images from Strapi API don't have width/height attributes in HTML.

Diagnosis:

  • Run PageSpeed Insights
  • Look for "Image elements do not have explicit width and height"
  • Check CLS in "Avoid large layout shifts" section

Solutions:

A. Always Include Dimensions from Strapi

Strapi provides image dimensions in the API response - use them!

// Next.js example
import Image from 'next/image';

interface StrapiImage {
  data: {
    attributes: {
      url: string;
      alternativeText: string | null;
      width: number; // Strapi provides this
      height: number; // Strapi provides this
    };
  };
}

export function ArticleImage({ image }: { image: StrapiImage }) {
  const { url, alternativeText, width, height } = image.data.attributes;

  return (
    <Image
      src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${url}`}
      alt={alternativeText || ''}
      width={width} // Use Strapi's dimensions
      height={height} // Use Strapi's dimensions
      sizes="(max-width: 768px) 100vw, 50vw"
    />
  );
}

B. Calculate Aspect Ratio

For responsive images, maintain aspect ratio:

// components/ResponsiveStrapiImage.tsx
export function ResponsiveStrapiImage({ image }: { image: StrapiImage }) {
  const { url, alternativeText, width, height } = image.data.attributes;
  const aspectRatio = (height / width) * 100;

  return (
    <div
      style={{
        position: 'relative',
        paddingBottom: `${aspectRatio}%`,
        width: '100%',
      }}
    >
      <Image
        src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${url}`}
        alt={alternativeText || ''}
        fill
        sizes="(max-width: 768px) 100vw, 50vw"
        style={{ objectFit: 'cover' }}
      />
    </div>
  );
}

C. Gatsby with gatsby-plugin-image

Gatsby automatically handles dimensions:

// src/templates/article.js
import { GatsbyImage, getImage } from 'gatsby-plugin-image';

export const ArticleTemplate = ({ data }) => {
  const image = getImage(data.strapiArticle.featuredImage.localFile);

  return (
    <GatsbyImage
      image={image} // Dimensions automatically included
      alt={data.strapiArticle.featuredImage.alternativeText || ''}
    />
  );
};

export const query = graphql`
  query($slug: String!) {
    strapiArticle(slug: { eq: $slug }) {
      featuredImage {
        alternativeText
        localFile {
          childImageSharp {
            gatsbyImageData(width: 1200, aspectRatio: 1.5)
          }
        }
      }
    }
  }
`;

D. Plain HTML Images

Even without a framework, use Strapi's dimension data:

// Vanilla JS/React
function StrapiImage({ image }) {
  const { url, alternativeText, width, height } = image.data.attributes;

  return (
    <img
      src={`${process.env.STRAPI_URL}${url}`}
      alt={alternativeText || ''}
      width={width}
      height={height}
      loading="lazy"
    />
  );
}

2. Dynamic Content from API Causing Shifts

Problem: Content loads from Strapi API and shifts layout.

A. Reserve Space for Content

Use loading skeletons that match content dimensions:

// components/ArticleContent.tsx
'use client';

import { useEffect, useState } from 'react';

export function ArticleContent({ slug }: { slug: string }) {
  const [article, setArticle] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`/api/articles/${slug}`)
      .then(r => r.json())
      .then(data => {
        setArticle(data);
        setLoading(false);
      });
  }, [slug]);

  if (loading) {
    return (
      <div className="article-skeleton">
        {/* Skeleton matches actual content layout */}
        <div className="skeleton-title" style={{ height: '48px', marginBottom: '16px' }} />
        <div className="skeleton-meta" style={{ height: '20px', marginBottom: '24px' }} />
        <div className="skeleton-image" style={{ height: '400px', marginBottom: '32px' }} />
        <div className="skeleton-content" style={{ height: '600px' }} />
      </div>
    );
  }

  return (
    <article>
      <h1>{article.attributes.title}</h1>
      {/* Actual content */}
    </article>
  );
}

B. Use SSR/SSG to Avoid Client-Side Loading

Best solution - fetch data server-side:

// app/articles/[slug]/page.tsx (Next.js App Router)
export default async function ArticlePage({ params }: { params: { slug: string } }) {
  // Data fetched on server - no layout shift
  const response = await fetch(
    `${process.env.STRAPI_URL}/api/articles?filters[slug][$eq]=${params.slug}&populate=*`
  );
  const { data } = await response.json();
  const article = data[0];

  return (
    <article>
      <h1>{article.attributes.title}</h1>
      {/* Content rendered immediately, no shift */}
    </article>
  );
}

3. Hydration Causing Layout Shifts

Problem: React hydration mismatch causes content to shift.

Diagnosis:

  • Warning in console: "Text content does not match server-rendered HTML"
  • Content flashes or shifts after initial render

Solutions:

A. Ensure Server/Client Consistency

// Bad - different output server vs client
export function FormattedDate({ date }: { date: string }) {
  return <time>{new Date(date).toLocaleDateString()}</time>;
  // Locale may differ between server and client
}

// Good - consistent output
export function FormattedDate({ date }: { date: string }) {
  return (
    <time dateTime={date}>
      {new Date(date).toLocaleDateString('en-US', {
        year: 'numeric',
        month: 'long',
        day: 'numeric',
        timeZone: 'UTC', // Explicit timezone
      })}
    </time>
  );
}

B. Use suppressHydrationWarning for Dynamic Content

// For content that must differ client/server
export function ViewCount({ articleId }: { articleId: number }) {
  const [views, setViews] = useState(0);

  useEffect(() => {
    // Only runs on client
    fetchViews(articleId).then(setViews);
  }, [articleId]);

  return (
    <span suppressHydrationWarning>
      {views > 0 ? `${views} views` : ''}
    </span>
  );
}

C. Delay Client-Only Content

'use client';

import { useEffect, useState } from 'react';

export function ClientOnlyContent({ children }) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) {
    return null; // Or return skeleton
  }

  return <>{children}</>;
}

4. Fonts Loading from Strapi or Frontend

Problem: Custom fonts cause text to reflow.

Solution - Next.js with next/font:

// app/layout.tsx
import { Inter, Playfair_Display } from 'next/font/google';

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

const playfair = Playfair_Display({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-playfair',
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${inter.variable} ${playfair.variable}`}>
      <body className={inter.className}>{children}</body>
    </html>
  );
}

Solution - Manual Font Optimization:

/* Use font-display: optional for minimal CLS */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom-font.woff2') format('woff2');
  font-display: optional; /* Only use font if available immediately */
  font-weight: 400;
  font-style: normal;
}

/* Or use fallback metrics to match custom font */
@font-face {
  font-family: 'CustomFontFallback';
  src: local('Arial');
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
  size-adjust: 107%;
}

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

5. Dynamic Zones Causing Shifts

Problem: Strapi Dynamic Zones components have varying heights.

Solution:

// Reserve minimum space for each component type
const COMPONENT_MIN_HEIGHTS = {
  'content.rich-text': 200,
  'content.image-gallery': 400,
  'content.video': 350,
  'content.quote': 150,
};

export function DynamicZone({ components }) {
  return (
    <div>
      {components.map((component) => {
        const minHeight = COMPONENT_MIN_HEIGHTS[component.__component] || 100;

        return (
          <div
            key={component.id}
            style={{ minHeight: `${minHeight}px` }}
          >
            <DynamicComponent component={component} />
          </div>
        );
      })}
    </div>
  );
}

6. Ads and Third-Party Embeds

Problem: Ad slots or embeds from Strapi content cause shifts.

Solutions:

A. Reserve Space for Ads

// components/AdSlot.tsx
export function AdSlot({ slotId, width, height }) {
  return (
    <div
      style={{
        width: `${width}px`,
        height: `${height}px`,
        position: 'relative',
      }}
    >
      <div id={slotId} />
    </div>
  );
}

B. Handle Strapi Rich Text Embeds

// components/StrapiRichText.tsx
import { BlocksRenderer } from '@strapi/blocks-react-renderer';

export function StrapiRichText({ content }) {
  return (
    <BlocksRenderer
      content={content}
      blocks={{
        image: ({ image }) => (
          <img
            src={image.url}
            alt={image.alternativeText}
            width={image.width}
            height={image.height}
          />
        ),
        // Handle other block types
      }}
    />
  );
}

7. Lazy-Loaded Content

Problem: Intersection Observer lazy loading causes shifts.

Solution - Use Next.js Image lazy loading (built-in):

// Automatically handles lazy loading without CLS
<Image
  src={imageUrl}
  width={800}
  height={600}
  loading="lazy" // Default behavior
  alt="..."
/>

Solution - Manual lazy loading with placeholder:

'use client';

import { useEffect, useRef, useState } from 'react';

export function LazyImage({ src, width, height, alt }) {
  const [loaded, setLoaded] = useState(false);
  const imgRef = useRef(null);

  useEffect(() => {
    if (!imgRef.current) return;

    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        setLoaded(true);
        observer.disconnect();
      }
    });

    observer.observe(imgRef.current);

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

  return (
    <div
      ref={imgRef}
      style={{
        width,
        height,
        backgroundColor: '#f0f0f0', // Placeholder color
      }}
    >
      {loaded && (
        <img
          src={src}
          alt={alt}
          width={width}
          height={height} => {
            // Image loaded
          }}
        />
      )}
    </div>
  );
}

Framework-Specific Solutions

Next.js

// next.config.js
module.exports = {
  images: {
    domains: [process.env.STRAPI_DOMAIN],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
    formats: ['image/avif', 'image/webp'],
  },
  experimental: {
    optimizeCss: true, // Reduce CSS-related CLS
  },
};

// Always use next/image
import Image from 'next/image';

// Dimensions from Strapi
<Image
  src={strapiImageUrl}
  width={strapiImage.width}
  height={strapiImage.height}
  alt={strapiImage.alternativeText}
/>

Gatsby

// gatsby-config.js
module.exports = {
  plugins: [
    {
      resolve: 'gatsby-plugin-image',
      options: {
        defaults: {
          placeholder: 'blurred', // Prevents CLS
          formats: ['auto', 'webp', 'avif'],
        },
      },
    },
  ],
};

Nuxt

<!-- Use NuxtImg component -->
<template>
  <NuxtImg
    :src="`${strapiUrl}${image.url}`"
    :width="image.width"
    :height="image.height"
    :alt="image.alternativeText"
    provider="strapi"
  />
</template>
// nuxt.config.ts
export default defineNuxtConfig({
  image: {
    strapi: {
      baseURL: process.env.STRAPI_URL,
    },
  },
});

Testing CLS

Use Chrome DevTools

  1. Open DevTools (F12)
  2. Go to Performance tab
  3. Record page load
  4. Look for "Experience" section
  5. Check "Layout Shift" events

PageSpeed Insights

  • Run PageSpeed Insights
  • Check CLS score
  • Review "Avoid large layout shifts" section
  • See which elements cause shifts

Real User Monitoring

// app/components/WebVitals.tsx
'use client';

import { useReportWebVitals } from 'next/web-vitals';

export function WebVitals() {
  useReportWebVitals((metric) => {
    if (metric.name === 'CLS') {
      // Send to analytics
      console.log('CLS:', metric.value);

      // Track in GA4
      if (typeof window !== 'undefined' && window.gtag) {
        window.gtag('event', 'web_vitals', {
          event_category: 'Web Vitals',
          event_label: metric.id,
          value: Math.round(metric.value * 1000),
          metric_name: 'CLS',
          non_interaction: true,
        });
      }
    }
  });

  return null;
}

Common CLS Patterns in Strapi Sites

Article Pages

// Reserve space for all article elements
export default async function ArticlePage({ params }) {
  const article = await fetchArticle(params.slug);

  return (
    <article>
      {/* Featured image with dimensions */}
      <Image
        src={`${STRAPI_URL}${article.featuredImage.data.attributes.url}`}
        width={article.featuredImage.data.attributes.width}
        height={article.featuredImage.data.attributes.height}
        alt={article.featuredImage.data.attributes.alternativeText}
        priority
      />

      {/* Title - no CLS */}
      <h1>{article.attributes.title}</h1>

      {/* Meta info - fixed height */}
      <div className="meta" style={{ minHeight: '24px' }}>
        {article.attributes.author?.data?.attributes?.name} •{' '}
        {new Date(article.attributes.publishedAt).toLocaleDateString()}
      </div>

      {/* Content */}
      <div>{article.attributes.content}</div>
    </article>
  );
}

Dynamic Content Grids

// Collection/category pages
export function ArticleGrid({ articles }) {
  return (
    <div className="grid grid-cols-3 gap-4">
      {articles.map((article) => (
        <article key={article.id} className="article-card">
          {/* Card with fixed aspect ratio */}
          <div className="aspect-ratio-box" style={{ paddingBottom: '66.67%' }}>
            <Image
              src={`${STRAPI_URL}${article.attributes.featuredImage.data.attributes.url}`}
              fill
              sizes="(max-width: 768px) 100vw, 33vw"
              alt={article.attributes.featuredImage.data.attributes.alternativeText}
            />
          </div>
          <h2>{article.attributes.title}</h2>
        </article>
      ))}
    </div>
  );
}

Quick Wins Checklist

  • Add width/height to all Strapi images
  • Use Next.js Image or framework equivalent
  • Implement SSR/SSG instead of client-side fetching
  • Reserve space with min-height for dynamic content
  • Use font-display: swap or optional
  • Fix hydration mismatches
  • Add aspect-ratio containers for responsive images
  • Use loading skeletons that match content
  • Test with PageSpeed Insights
  • Monitor real user CLS with Web Vitals API

Debugging CLS

Identify Shifting Elements

// Add to browser console
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      console.log('Layout shift:', entry);
      console.log('Affected elements:', entry.sources);
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

Check Specific Elements

/* Add outlines to debug */
img {
  outline: 2px solid red;
}

[style*="min-height"] {
  outline: 2px solid blue;
}

When to Hire a Developer

Consider hiring help if:

  • CLS consistently over 0.25 after fixes
  • Complex dynamic content layouts
  • Framework hydration issues persist
  • Need custom skeleton loading system
  • Large-scale CLS optimization needed

Next Steps

For general CLS optimization strategies, see CLS Optimization Guide.