Largest Contentful Paint (LCP) measures how quickly the main content of your Strapi-powered site loads. Since Strapi is a headless CMS, LCP optimization focuses on both backend API performance and frontend rendering efficiency.
Target: LCP under 2.5 seconds Good: Under 2.5s | Needs Improvement: 2.5-4.0s | Poor: Over 4.0s
For general LCP concepts, see the global LCP guide.
Strapi-Specific LCP Issues
1. Slow API Response Time (TTFB)
The most critical factor for Strapi-powered sites is Time to First Byte (TTFB) - how quickly your Strapi API responds.
Problem: Slow Strapi API responses delay entire page render.
Diagnosis:
- Open Chrome DevTools → Network tab
- Look for requests to your Strapi API
- Check "Waiting (TTFB)" time
- Target: TTFB under 200ms
Solutions:
A. Enable Strapi Response Caching
# Install Strapi cache plugin
npm install @strapi/plugin-rest-cache
// config/plugins.js
module.exports = {
'rest-cache': {
enabled: true,
config: {
provider: {
name: 'memory',
options: {
max: 100,
maxAge: 3600000, // 1 hour
},
},
strategy: {
contentTypes: [
'api::article.article',
'api::category.category',
'api::author.author',
],
maxAge: 3600000,
},
},
},
};
B. Optimize Strapi Database Queries
Add indexes to frequently queried fields:
// Strapi model schema
{
"kind": "collectionType",
"collectionName": "articles",
"info": {
"singularName": "article",
"pluralName": "articles"
},
"attributes": {
"slug": {
"type": "string",
"unique": true,
"index": true // Add index for faster queries
},
"publishedAt": {
"type": "datetime",
"index": true // Index for sorting/filtering
}
}
}
C. Use Field Selection to Reduce Payload
Only fetch needed fields:
// Bad - fetches everything
const response = await fetch(
`${STRAPI_URL}/api/articles?populate=*`
);
// Good - only fetch what you need
const response = await fetch(
`${STRAPI_URL}/api/articles?fields[0]=title&fields[1]=slug&fields[2]=excerpt&populate[featuredImage][fields][0]=url&populate[featuredImage][fields][1]=alternativeText`
);
D. Implement CDN for Strapi API
Use a CDN like Cloudflare to cache API responses:
// Add cache headers in Strapi
// config/middlewares.js
module.exports = [
// ...
{
name: 'strapi::public',
config: {
defer: false,
maxAge: 31536000, // 1 year for static assets
},
},
];
Or use Next.js as a caching layer:
// app/api/articles/[slug]/route.ts
export async function GET(
request: Request,
{ params }: { params: { slug: string } }
) {
const response = await fetch(
`${process.env.STRAPI_URL}/api/articles?filters[slug][$eq]=${params.slug}&populate=*`,
{
next: { revalidate: 3600 }, // Cache for 1 hour
}
);
const data = await response.json();
return Response.json(data);
}
2. Unoptimized Images from Strapi
Images from Strapi's media library often lack proper optimization.
Problem: Large, unoptimized images as LCP element.
Diagnosis:
- Run PageSpeed Insights
- Check if Strapi images flagged as LCP element
- Look for "Properly size images" warning
Solutions:
A. Use Next.js Image Component
// components/StrapiImage.tsx
import Image from 'next/image';
interface StrapiImageProps {
image: {
data: {
attributes: {
url: string;
alternativeText: string | null;
width: number;
height: number;
};
};
};
priority?: boolean;
}
export function StrapiImage({ image, priority = false }: StrapiImageProps) {
const { url, alternativeText, width, height } = image.data.attributes;
return (
<Image
src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${url}`}
alt={alternativeText || ''}
width={width}
height={height}
priority={priority} // For LCP images
quality={80}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
);
}
Usage:
// app/articles/[slug]/page.tsx
<StrapiImage
image={article.attributes.featuredImage}
priority={true} // Mark as LCP element
/>
B. Configure Image Optimization Provider
Use Cloudinary, ImageKit, or similar for Strapi:
npm install @strapi/provider-upload-cloudinary
// config/plugins.js
module.exports = {
upload: {
config: {
provider: 'cloudinary',
providerOptions: {
cloud_name: process.env.CLOUDINARY_NAME,
api_key: process.env.CLOUDINARY_KEY,
api_secret: process.env.CLOUDINARY_SECRET,
},
actionOptions: {
upload: {},
uploadStream: {},
delete: {},
},
},
},
};
Then use Cloudinary transformations:
const optimizedUrl = `https://res.cloudinary.com/${cloudName}/image/upload/f_auto,q_auto,w_800/${publicId}`;
C. Implement Responsive Images
// Gatsby example
import { GatsbyImage, getImage } from 'gatsby-plugin-image';
export const ArticleTemplate = ({ data }) => {
const image = getImage(data.strapiArticle.featuredImage.localFile);
return (
<GatsbyImage
image={image}
alt={data.strapiArticle.featuredImage.alternativeText || ''}
loading="eager" // For LCP images
/>
);
};
export const query = graphql`
query($slug: String!) {
strapiArticle(slug: { eq: $slug }) {
featuredImage {
alternativeText
localFile {
childImageSharp {
gatsbyImageData(
width: 1200
placeholder: BLURRED
formats: [AUTO, WEBP, AVIF]
)
}
}
}
}
}
`;
D. Preload LCP Image
// app/layout.tsx or page.tsx
export default function ArticlePage({ article }) {
const featuredImageUrl = `${process.env.NEXT_PUBLIC_STRAPI_URL}${article.attributes.featuredImage.data.attributes.url}`;
return (
<>
<head>
<link
rel="preload"
as="image"
href={featuredImageUrl}
imageSrcSet={`${featuredImageUrl}?width=640 640w, ${featuredImageUrl}?width=1024 1024w, ${featuredImageUrl}?width=1920 1920w`}
imageSizes="100vw"
/>
</head>
<article>
{/* Article content */}
</article>
</>
);
}
3. Framework Rendering Strategy
Choose the right rendering strategy for your use case.
Problem: Wrong rendering strategy causes slow initial load.
Next.js Rendering Options
Static Site Generation (SSG) - Best for LCP:
// app/articles/[slug]/page.tsx
export async function generateStaticParams() {
const articles = await fetch(`${process.env.STRAPI_URL}/api/articles?fields[0]=slug`).then(r => r.json());
return articles.data.map((article: any) => ({
slug: article.attributes.slug,
}));
}
export default async function ArticlePage({ params }: { params: { slug: string } }) {
// This is generated at build time
const article = await fetch(
`${process.env.STRAPI_URL}/api/articles?filters[slug][$eq]=${params.slug}&populate=*`
).then(r => r.json());
return <ArticleContent article={article.data[0]} />;
}
Incremental Static Regeneration (ISR) - Good balance:
export const revalidate = 3600; // Revalidate every hour
export default async function ArticlePage({ params }: { params: { slug: string } }) {
const article = await fetch(
`${process.env.STRAPI_URL}/api/articles?filters[slug][$eq]=${params.slug}&populate=*`,
{ next: { revalidate: 3600 } }
).then(r => r.json());
return <ArticleContent article={article.data[0]} />;
}
Server-Side Rendering (SSR) - Use sparingly:
// Only use SSR when content must be fresh every request
export const dynamic = 'force-dynamic';
export default async function ArticlePage({ params }: { params: { slug: string } }) {
// Fetched on every request - slower LCP
const article = await fetch(
`${process.env.STRAPI_URL}/api/articles?filters[slug][$eq]=${params.slug}&populate=*`,
{ cache: 'no-store' }
).then(r => r.json());
return <ArticleContent article={article.data[0]} />;
}
Gatsby - Optimized by Default
Gatsby builds static pages at build time:
// gatsby-node.js
exports.createPages = async ({ graphql, actions }) => {
const { createPage } = actions;
const result = await graphql(`
query {
allStrapiArticle {
nodes {
id
slug
}
}
}
`);
result.data.allStrapiArticle.nodes.forEach(article => {
createPage({
path: `/articles/${article.slug}`,
component: require.resolve('./src/templates/article.js'),
context: {
id: article.id,
},
});
});
};
4. Multiple API Calls Blocking Render
Problem: Multiple sequential API calls delay LCP.
Bad - Sequential Calls:
// Each call waits for previous one
const article = await fetch(`${STRAPI_URL}/api/articles/${id}`).then(r => r.json());
const author = await fetch(`${STRAPI_URL}/api/users/${article.data.attributes.author}`).then(r => r.json());
const category = await fetch(`${STRAPI_URL}/api/categories/${article.data.attributes.category}`).then(r => r.json());
Good - Use Populate:
// Single call with all data
const article = await fetch(
`${STRAPI_URL}/api/articles/${id}?populate[author]=*&populate[category]=*&populate[featuredImage]=*`
).then(r => r.json());
Best - Use GraphQL:
// Fetch exactly what you need in one request
const { data } = await fetch(`${STRAPI_URL}/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `
query GetArticle($slug: String!) {
articles(filters: { slug: { eq: $slug } }) {
data {
id
attributes {
title
content
author {
data {
attributes {
name
}
}
}
category {
data {
attributes {
name
}
}
}
featuredImage {
data {
attributes {
url
alternativeText
width
height
}
}
}
}
}
}
}
`,
variables: { slug },
}),
}).then(r => r.json());
5. Heavy JavaScript Bundles
Problem: Large JavaScript bundles delay interactivity and LCP.
Diagnosis:
- Use bundle analyzer:
# Next.js
npm install @next/bundle-analyzer
# next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// config
});
# Run analysis
ANALYZE=true npm run build
Solutions:
A. Code Splitting
// Lazy load non-critical components
import dynamic from 'next/dynamic';
const Comments = dynamic(() => import('./Comments'), {
loading: () => <p>Loading comments...</p>,
ssr: false, // Don't render on server
});
const RelatedArticles = dynamic(() => import('./RelatedArticles'));
export default function ArticlePage({ article }) {
return (
<article>
<h1>{article.attributes.title}</h1>
<div>{article.attributes.content}</div>
{/* Lazy loaded below fold */}
<RelatedArticles articles={article.attributes.related} />
<Comments articleId={article.id} />
</article>
);
}
B. Optimize Dependencies
// Bad - imports entire library
import { format } from 'date-fns';
// Good - import only what you need
import format from 'date-fns/format';
// Or use lighter alternatives
import { formatDate } from '@/utils/dates'; // Custom lightweight utility
6. Font Loading
Problem: Custom fonts block text rendering.
Solution - Next.js with next/font:
// app/layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Use fallback font immediately
preload: true,
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}
Solution - Manual Font Loading:
/* Use font-display: swap */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom-font.woff2') format('woff2');
font-display: swap; /* Show fallback immediately */
font-weight: 400;
font-style: normal;
}
Framework-Specific Optimizations
Next.js
// next.config.js
module.exports = {
images: {
domains: [process.env.STRAPI_DOMAIN],
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
},
compiler: {
removeConsole: process.env.NODE_ENV === 'production',
},
experimental: {
optimizeCss: true,
},
};
Gatsby
// gatsby-config.js
module.exports = {
plugins: [
{
resolve: 'gatsby-plugin-image',
options: {
defaults: {
formats: ['auto', 'webp', 'avif'],
placeholder: 'blurred',
quality: 80,
},
},
},
{
resolve: 'gatsby-source-strapi',
options: {
apiURL: process.env.STRAPI_URL,
collectionTypes: ['article'],
singleTypes: ['homepage'],
queryLimit: 1000,
},
},
],
};
Nuxt
// nuxt.config.ts
export default defineNuxtConfig({
image: {
provider: 'strapi',
strapi: {
baseURL: process.env.STRAPI_URL,
},
formats: ['webp', 'avif'],
},
nitro: {
compressPublicAssets: true,
prerender: {
crawlLinks: true,
routes: ['/'],
},
},
});
Testing & Monitoring
Test LCP
Tools:
- PageSpeed Insights - Lab and field data
- WebPageTest - Detailed waterfall
- Chrome DevTools Lighthouse - Local testing
Test Different Pages:
- Homepage (often uses Strapi Single Type)
- Article pages (Collection Type)
- Category pages (List views)
- Search results
Test Different Rendering Methods:
- SSG pages
- ISR pages
- SSR pages
- Client-side rendered content
Monitor LCP Over Time
Real User Monitoring:
// app/layout.tsx
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export function WebVitals() {
useReportWebVitals((metric) => {
if (metric.name === 'LCP') {
// Send to analytics
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify({ metric }),
});
}
});
}
Quick Wins Checklist
Start here for immediate LCP improvements:
- Enable Strapi REST cache plugin
- Use Next.js Image component (or framework equivalent)
- Implement SSG/ISR instead of SSR
- Use
populateparameter to reduce API calls - Add
priorityprop to LCP images - Optimize Strapi database queries (add indexes)
- Configure CDN for Strapi media
- Use GraphQL for precise data fetching
- Lazy load below-fold components
- Implement code splitting
- Use
font-display: swapfor custom fonts - Test with PageSpeed Insights
When to Hire a Developer
Consider hiring a Strapi or frontend developer if:
- LCP consistently over 4 seconds after optimizations
- Complex SSR/SSG/ISR implementation needed
- Custom Strapi plugin required for caching
- Large-scale performance optimization project
- Need to migrate rendering strategy
Next Steps
For general LCP optimization strategies, see LCP Optimization Guide.