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
- Open DevTools (F12)
- Go to Performance tab
- Record page load
- Look for "Experience" section
- 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.