Fix LCP in Contentful
Largest Contentful Paint (LCP) on a Contentful-powered site usually fails for one of three reasons: the front-end calls the Content Delivery API (CDA) on every request, the hero image is served as the raw upload without any Images API transformation, or the framework's image component is bypassed and a third-party placeholder swap blocks the LCP candidate. Contentful itself is fast -- both cdn.contentful.com and images.ctfassets.net are CDN-fronted and routinely return responses in under 80ms from edge regions. The fixes below align your front-end with what Contentful already does well, and remove the runtime CDA round-trip from the critical path. Most teams move from a 3.5 to 5 second LCP into Google's Good band (under 2.5 seconds) by working through these five steps in order, and the changes are framework-agnostic enough to apply to Next.js, Astro, SvelteKit, Nuxt, or Remix front-ends.
Expected results
Before
4.4s
LCP (Poor) -- raw image, REST CDA on request, no preload, third-party placeholder swap
After
1.7s
LCP (Good) -- Images API AVIF, GraphQL projection, ISR cache, preload + priority
Step-by-step fix
Use the Contentful Images API with explicit width, format, and quality
Contentful stores every uploaded image as a single source asset on images.ctfassets.net and exposes a transformation pipeline driven by query string parameters. The pipeline can resize (w, h), reformat (fm), recompress (q), crop and focus (fit, f), and apply progressive encoding (fl=progressive) all on demand. The mistake most teams make is rendering the raw asset URL directly, which serves the full-resolution original in whatever format the editor uploaded, often a 2400-pixel-wide JPEG at quality 95. For an LCP hero that ends up about 1200 pixels wide on desktop, you almost never want more than 1500 pixels of width, quality 75 to 80, and fm=avif for browsers that support it. Payload typically drops from 600KB on the original JPEG to under 90KB on AVIF, which is the single biggest LCP improvement on most Contentful sites.
type ContentfulAsset = {
url: string; // //images.ctfassets.net/space/asset/file.jpg
details: { image: { width: number; height: number } };
};
// Bad: raw asset URL, full resolution, original format
// const heroSrc = `https:${asset.url}`;
// Good: explicit width, AVIF, quality 78, progressive
export function heroImage(asset: ContentfulAsset, width = 1500) {
const base = `https:${asset.url}`;
const params = new URLSearchParams({
w: String(width),
fm: 'avif',
q: '78',
fit: 'fill',
fl: 'progressive',
});
return `${base}?${params.toString()}`;
}
// Responsive srcset across breakpoints
export function heroSrcset(asset: ContentfulAsset) {
return [400, 800, 1200, 1500]
.map(w => `${heroImage(asset, w)} ${w}w`)
.join(', ');
}
Project only the fields you need with the GraphQL Content API
A common Contentful anti-pattern is calling the REST Content Delivery API with include=10 and never narrowing the response. The REST endpoint returns the full entry, every linked asset, and every linked entry up to 10 levels deep -- a payload that routinely reaches 40 to 80 KB on a richly authored page. The GraphQL Content API lets you select exactly the fields you render. A hero-image-only query for a blog post returns under 4 KB. Always pull the asset's width and height so the front-end can set image dimensions without a second round-trip, and request the url field rather than the entire file object.
# Bad: REST CDA with include=10 returns the entire linked graph
# GET /spaces/SPACE/entries?content_type=post&fields.slug=$slug&include=10
# Good: GraphQL projection, only what the page renders
query PostBySlug($slug: String!) {
postCollection(where: { slug: $slug }, limit: 1) {
items {
sys { id }
title
slug
publishedAt: sys { publishedAt }
excerpt
hero {
title
description
url
width
height
}
author { name slug }
}
}
}
Preload the LCP image at the page boundary
Even with a fast Images API and a tight GraphQL 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 same Images API helper from step 1 so you preload exactly what you render. 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 { heroImage, heroSrcset } from '@/lib/contentful-image';
import { contentfulFetch } from '@/lib/contentful-client';
import { POST_BY_SLUG } from '@/queries/post-by-slug';
export default async function PostPage({ params }: { params: { slug: string } }) {
const data = await contentfulFetch(POST_BY_SLUG, { slug: params.slug }, {
next: { tags: [`post:${params.slug}`] }
});
const post = data.postCollection.items[0];
const heroSrc = heroImage(post.hero);
const heroSet = heroSrcset(post.hero);
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>{post.title}</h1>
{/* hero image rendered with framework component below */}
</article>
</>
);
}
Cache the Content Delivery API response with ISR and Contentful webhooks
A CDA call that runs on every request is by far the largest contributor to a slow LCP on Contentful sites. The fix is to render statically and revalidate on demand. Next.js exposes revalidateTag, which pairs with the next.tags option on each fetch and lets your Contentful webhook bust the cache for one entry at a time. Astro's static adapter and SvelteKit's adapter-static do the same thing on rebuild, and Contentful's built-in webhooks can call your build hook on publish. Whichever framework you choose, the goal is the same -- the CDA round-trip moves out of the user's request path. Restrict webhook filters to the content types and environments you actually deploy from so a staging publish does not nuke the production cache.
import { revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(req: NextRequest) {
const secret = req.headers.get('x-contentful-webhook-secret');
if (secret !== process.env.CONTENTFUL_WEBHOOK_SECRET) {
return new Response('Unauthorized', { status: 401 });
}
const body = await req.json();
// Contentful webhook payload includes sys.id and sys.contentType.sys.id
const contentType = body.sys?.contentType?.sys?.id;
const slug = body.fields?.slug?.['en-US'];
if (contentType === 'post' && slug) {
revalidateTag(`post:${slug}`);
}
return Response.json({ revalidated: true });
}
// Configure the Contentful webhook (Settings > Webhooks):
// URL: https://your-site.com/api/revalidate
// Triggers: Publish, Unpublish
// Filters: sys.environment.sys.id == "master"
// sys.contentType.sys.id in ["post"]
// Headers: X-Contentful-Webhook-Secret: $CONTENTFUL_WEBHOOK_SECRET
Use the framework image component with a Contentful loader
The final step is the most overlooked. Once the Images API 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 takes the asset and a requested width, then returns a Contentful Images API URL -- 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 Contentful, 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 placeholder to 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';
const contentfulLoader: ImageLoader = ({ src, width, quality }) => {
const params = new URLSearchParams({
w: String(width),
fm: 'avif',
q: String(quality ?? 78),
fit: 'fill',
});
return `${src}?${params.toString()}`;
};
type Props = {
hero: { url: string; width: number; height: number; title: string };
};
export function HeroImage({ hero }: Props) {
return (
<Image
loader={contentfulLoader}
src={`https:${hero.url}`}
alt={hero.title}
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
-
Hero rendered via the Images API with explicit
w,fm=avif, andq=75-80 -
GraphQL Content API used instead of REST CDA with
include=N, projection narrowed to render-only fields -
rel="preload"link withimagesrcsetandfetchpriority="high"emitted in head on server -
Contentful webhook posts to
/api/revalidate; production never runs CDA on the request path -
Hero rendered through
next/image,astro:assets, orenhanced-imgwithpriorityand a Contentful 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. Contentful's Content Delivery API and Images API are both CDN-fronted, so most of the LCP budget goes to framework hydration and image bytes. Pages that fetch on every request commonly score 3.2 to 4.8 seconds. Pages that pre-render with ISR and use the Images API correctly land comfortably under 2.0 seconds.
Yes. The Images API supports fm=avif, fm=webp, fm=jpg, and fm=png as explicit format selectors. Unlike some CDNs, Contentful does not currently auto-negotiate based on the Accept header, so you choose the format yourself. The standard pattern is to emit AVIF for the LCP image, WebP as a fallback in a <picture> source, and JPEG as a final fallback for very old browsers.
Use GraphQL when LCP matters. The REST Content Delivery API requires include=N to resolve linked entries and returns the full graph in a single response, which is convenient but heavy. The GraphQL Content API lets you select exactly the fields you need, so a hero-image-only payload stays under 4 KB instead of the 40 to 80 KB the REST endpoint typically returns for a richly linked entry.
Compute the URL during server-side render. In Next.js App Router, build the Images API URL and imagesrcset string in your page server component and pass them to a preload component that injects a <link rel=preload> tag. 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.
Contentful does not ship a built-in low-quality placeholder, but several community loaders (the next-contentful-image package and the @contentful-loaders set) implement one. For the LCP element, disable any placeholder behavior. The swap from a 1 KB blurred placeholder to the full image counts as the LCP event and will inflate your measurement. Use placeholders only for below-fold images.
Use a separate preview route and a separate access token. The Content Preview API has different rate limits and does not serve from the same CDN tier as the Content Delivery API. Set NEXT_PUBLIC_CONTENTFUL_PREVIEW=false in production builds, gate preview behind a cookie or query parameter, and never route public traffic through the preview client. Production should only ever talk to cdn.contentful.com and images.ctfassets.net.
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 -- many of the same principles with Sanity's image-url builder and GROQ projections.
FixImage Optimization for LCP
Cross-framework guide to AVIF, WebP, srcset, sizes, and responsive hero patterns.