Fix LCP in Strapi
Largest Contentful Paint (LCP) on a Strapi-powered site usually fails for one of three reasons: the front-end calls the Content API on every request without a cache layer, the hero image is served by the default local upload provider straight from the Strapi node process, or populate=* is fanning out the response to dozens of kilobytes when the page only renders four fields. Strapi the API is fast once the response is cached or served through a CDN -- the slowness comes from running it as if it were a database. The five fixes below put a CDN in front of media, narrow the Content API response, move the fetch out of the request path with ISR, and wire the framework image component to the Strapi CDN. Most teams move from a 3.5 to 5.5 second LCP into Google's Good band (under 2.5 seconds) by working through them in order, and the changes apply equally to Next.js, Astro, SvelteKit, Nuxt, or Remix front-ends.
Expected results
Before
4.6s
LCP (Poor) -- local upload provider, populate=*, Content API on request, no preload
After
1.8s
LCP (Good) -- S3+CloudFront AVIF, projected response, ISR cache, preload + priority
Step-by-step fix
Configure an external upload provider with responsive formats
The single biggest LCP improvement on most Strapi sites is moving uploads off the local provider. By default, Strapi stores files in ./public/uploads and serves them from the Strapi node process, which means every hero image is hit-or-miss on whatever CPU and bandwidth your CMS host has, with no edge cache, no format negotiation, and no responsive sizing. Switch to @strapi/provider-upload-aws-s3 with a CloudFront distribution, or @strapi/provider-upload-cloudinary, and set sizeOptimization and responsiveDimensions on the upload plugin. Strapi generates small (500w), medium (750w), large (1000w), and thumbnail renditions on upload and stores them next to the original. For an LCP hero that ends up about 1200 pixels wide on desktop, you serve the large rendition through the CDN at AVIF, and payload drops from a 700KB original JPEG to under 80KB.
export default ({ env }) => ({
upload: {
config: {
provider: 'aws-s3',
providerOptions: {
s3Options: {
credentials: {
accessKeyId: env('AWS_ACCESS_KEY_ID'),
secretAccessKey: env('AWS_ACCESS_SECRET'),
},
region: env('AWS_REGION'),
params: { Bucket: env('AWS_BUCKET') },
},
// Front the bucket with CloudFront (or Cloudflare R2 + CDN)
baseUrl: env('CDN_BASE_URL'), // https://cdn.example.com
},
// Generate AVIF/WebP renditions on upload
sizeOptimization: true,
responsiveDimensions: true,
breakpoints: {
large: 1200,
medium: 750,
small: 500,
thumbnail: 245,
},
// Bad: PNG/JPEG only (Strapi default)
// Good: emit AVIF + WebP renditions, fall back to original
formats: ['avif', 'webp', 'original'],
quality: 78,
},
},
});
Project only the fields you render with populate and fields
A common Strapi anti-pattern is calling the Content API with ?populate=* and reading the same blob on every page. The wildcard tells Strapi to resolve every relation, component, and dynamic zone on the entry. For a blog post with an author relation, three components, a SEO dynamic zone, and a media library, the payload routinely reaches 30 to 90 KB. Replace it with an explicit populate object that names only the relations the page renders, plus a fields array on each level so you drop attributes you do not use. A hero-image-only request returns under 4 KB. Strapi 5 makes this easier with the flatter response shape and the fields option on populate, so prefer it over Strapi 4 patterns when you can.
// Bad: returns the whole graph, 30 to 90 KB on a real blog post
// const url = `${STRAPI_URL}/api/articles?filters[slug][$eq]=${slug}&populate=*`;
// Good: tight projection, under 4 KB
export function articleBySlugUrl(slug: string) {
const qs = new URLSearchParams({
'filters[slug][$eq]': slug,
'fields[0]': 'title',
'fields[1]': 'slug',
'fields[2]': 'publishedAt',
'fields[3]': 'excerpt',
'populate[hero][fields][0]': 'url',
'populate[hero][fields][1]': 'width',
'populate[hero][fields][2]': 'height',
'populate[hero][fields][3]': 'alternativeText',
'populate[hero][fields][4]': 'formats',
'populate[author][fields][0]': 'name',
'populate[author][fields][1]': 'slug',
'pagination[limit]': '1',
});
return `${STRAPI_URL}/api/articles?${qs.toString()}`;
}
Preload the LCP image at the page boundary
Even with a fast CDN and a tight Content API payload, the browser cannot start fetching the hero image until it has parsed the <img> tag. With most JavaScript frameworks that tag does not appear in the initial HTML until well into the document, and on streaming responses it can arrive even later. A rel="preload" link tag in the <head> tells the browser to begin the fetch on byte zero of the response. Compute the URL on the server using the responsive formats object that Strapi returned (it contains large, medium, and small entries with the rendition URLs). On Next.js App Router, this lives in your page server component. On Astro, generate it in the frontmatter. On SvelteKit, generate it in +page.server.ts and inject from the layout.
import { strapiFetch } from '@/lib/strapi-client';
import { articleBySlugUrl } from '@/lib/strapi';
export default async function ArticlePage({ params }: { params: { slug: string } }) {
const data = await strapiFetch(articleBySlugUrl(params.slug), {
next: { tags: [`article:${params.slug}`] },
});
const article = data.data[0];
const hero = article.hero;
// Strapi returns formats: { large: {url,width,...}, medium: {...}, small: {...} }
const heroSrc = hero.formats.large.url;
const heroSet = ['small', 'medium', 'large']
.map((k) => `${hero.formats[k].url} ${hero.formats[k].width}w`)
.join(', ');
return (
<>
{/* Preload the hero before any layout work happens */}
<link
rel="preload"
as="image"
href={heroSrc}
imageSrcSet={heroSet}
imageSizes="(max-width: 768px) 100vw, 1200px"
fetchPriority="high"
/>
<article>
<h1>{article.title}</h1>
{/* hero rendered with framework component below */}
</article>
</>
);
}
Cache the Content API response with ISR and Strapi lifecycle webhooks
A Content API call that runs on every request is by far the largest contributor to a slow LCP on Strapi sites. The fix is to render statically and revalidate on demand. Register a lifecycle hook in your Strapi API (or use Strapi's built-in webhooks) so that afterUpdate and afterPublish POST to your front-end's revalidate endpoint with the entity id and slug. Next.js exposes revalidateTag, which pairs with the next.tags option on each fetch and lets a single Strapi save bust exactly one page in the cache. Astro's static adapter and SvelteKit's adapter-static can trigger an incremental rebuild of the affected route. The shared rule across frameworks: the Content API round-trip moves out of the user's request path entirely.
// Strapi lifecycle hook that calls the front-end revalidate endpoint
export default {
async afterUpdate(event) {
await notifyFrontend(event.result);
},
async afterCreate(event) {
await notifyFrontend(event.result);
},
};
async function notifyFrontend(entity: any) {
if (!entity?.slug) return;
await fetch(`${process.env.FRONTEND_URL}/api/revalidate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-strapi-webhook-secret': process.env.STRAPI_WEBHOOK_SECRET!,
},
body: JSON.stringify({
contentType: 'article',
id: entity.id,
slug: entity.slug,
}),
});
}
// app/api/revalidate/route.ts on the Next.js front-end
//
// import { revalidateTag } from 'next/cache';
// import { NextRequest } from 'next/server';
//
// export async function POST(req: NextRequest) {
// if (req.headers.get('x-strapi-webhook-secret') !== process.env.STRAPI_WEBHOOK_SECRET) {
// return new Response('Unauthorized', { status: 401 });
// }
// const { contentType, slug } = await req.json();
// if (contentType === 'article' && slug) {
// revalidateTag(`article:${slug}`);
// }
// return Response.json({ revalidated: true });
// }
Use the framework image component with a Strapi-aware loader
The final step is the most overlooked. Once the CDN URL is right and the preload is in place, render the hero through your framework's image component rather than a raw <img> tag. next/image accepts a custom loader prop that picks the right Strapi rendition for the requested width -- this gives you responsive srcset and lazy loading out of the box, plus fetchpriority=high when you pass priority. astro:assets can be configured with a custom image service that maps to the Strapi CDN, and @sveltejs/enhanced-img accepts an external loader for the same purpose. Whichever component you use, disable any third-party placeholder library on the LCP element. The swap from a 1 KB blurred placeholder to the full image counts as the LCP event and inflates the measurement. Below-fold placeholders are fine and help with perceived performance.
import Image, { ImageLoader } from 'next/image';
type StrapiFormats = {
small: { url: string; width: number };
medium: { url: string; width: number };
large: { url: string; width: number };
};
const strapiLoader = (formats: StrapiFormats): ImageLoader =>
({ width }) => {
if (width <= formats.small.width) return formats.small.url;
if (width <= formats.medium.width) return formats.medium.url;
return formats.large.url;
};
type Props = {
hero: {
url: string;
width: number;
height: number;
alternativeText?: string;
formats: StrapiFormats;
};
};
export function HeroImage({ hero }: Props) {
return (
<Image
loader={strapiLoader(hero.formats)}
src={hero.formats.large.url}
alt={hero.alternativeText ?? ''}
width={hero.width}
height={hero.height}
sizes="(max-width: 768px) 100vw, 1200px"
priority // sets fetchpriority=high, skips lazy
placeholder="empty" // never LQIP the LCP element
quality={78}
/>
);
}
Quick checklist
-
External upload provider (S3+CDN or Cloudinary) with
sizeOptimization,responsiveDimensions, AVIF/WebP renditions -
No more
populate=*; explicitpopulateobject plusfieldsarrays per request -
rel="preload"link withimagesrcsetandfetchpriority="high"emitted in head on server -
Lifecycle hook or built-in webhook posts to
/api/revalidate; production never runs Content API on the request path -
Hero rendered through
next/image,astro:assets, orenhanced-imgwithpriorityand a Strapi-aware loader - Field LCP measured in PageSpeed Insights at the 75th percentile after 28 days of CrUX data
Frequently asked questions
Aim for an LCP under 2.0 seconds at the 75th percentile from Chrome User Experience Report data. Strapi the API is fast once the response is cached or served via CDN, so almost all of the LCP budget on a slow Strapi site is wasted on a synchronous Content API round trip and an unoptimized hero image. Pages that fetch on every request commonly score 3.6 to 5.2 seconds. Pages that pre-render with ISR and offload images to a CDN provider land comfortably under 2.0 seconds.
Not by default. The local upload provider stores whatever the editor uploaded -- usually a JPEG or PNG -- and serves it unchanged. To emit AVIF and WebP, switch to an external provider (S3+CloudFront, Cloudinary, or Imgix) with sizeOptimization and responsiveDimensions enabled in the upload plugin config, and Strapi will generate small, medium, large, and thumbnail renditions on upload. For on-the-fly format negotiation, front the bucket with a CDN that supports content negotiation (CloudFront with Lambda@Edge, or Cloudinary's f_auto).
Either is fine for LCP -- the response shape is what matters. With REST, use the populate object syntax and the fields array to narrow the response. With the GraphQL plugin, write tight queries and never request the full media object when you only need url, width, height, and alternativeText. A well-projected REST call and a well-projected GraphQL query both return under 4 KB for a hero-image-only payload.
Compute the URL during server-side render. In Next.js App Router, build the CDN URL and imagesrcset string in your page server component and pass them to a preload component that injects a <link rel=preload> tag with fetchpriority=high. In Astro, do this in the frontmatter. In SvelteKit, use +page.server.ts and emit the link from the layout. The preload tag must appear in the HTML the browser parses, not be added by client JavaScript after hydration.
Use Strapi lifecycle hooks. Register an afterUpdate or afterPublish hook on the relevant content type that POSTs to your front-end revalidate endpoint with the entity id and slug. On Next.js, that endpoint calls revalidateTag(`article:${slug}`). On Astro or SvelteKit static builds, the same endpoint triggers an incremental rebuild of just the affected route. Restrict the webhook to the production environment and content types you actually publish so a draft save does not nuke the public cache.
Less than you would think. The bottleneck on most Strapi sites is the front-end fetching on every request and serving an unoptimized hero, not the Strapi instance itself. Strapi Cloud puts the Content API behind a CDN by default, which helps if you cannot pre-render, but self-hosted Strapi behind CloudFront or Cloudflare gives you the same characteristics. The bigger LCP win is always moving the fetch out of the request path with ISR.
Related resources
Complete LCP Guide
Deep dive into LCP measurement, thresholds, element types, and optimization strategies for any stack.
FixFix LCP in Sanity
Sister headless-CMS guide -- the same five principles with Sanity's image-url builder and GROQ projections.
FixFix LCP in Contentful
Sister headless-CMS guide -- Images API tuning, GraphQL projection, ISR with Contentful webhooks.