next/image takes care of a lot of image SEO and performance work by default. It generates a srcset, serves WebP, lazy-loads, and reserves space so the layout does not jump. Those defaults are good enough that many teams stop looking at their images at all.
The problems that remain come from four decisions the component cannot make for you: which image is the LCP element, how wide each image actually renders, what the alt text says, and whether the image is in the HTML at all. Here is how to get each one right, plus a console snippet that checks a page in a few seconds.
1. The hero image is lazy-loaded
loading="lazy" is the default, and for most images that is correct. For the large image at the top of a product or article page it is usually wrong. That image is often the Largest Contentful Paint element, and lazy loading means the browser waits for layout before it even requests the file.
Next.js 16 deprecated the old priority prop in favor of preload. The docs are clear that in most cases you should use loading="eager" or fetchPriority="high" instead of preload:
// app/products/[slug]/page.tsx
import Image from "next/image";
import { getProduct } from "@/lib/catalog";
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const product = await getProduct(slug);
return (
<article>
<Image
src={product.hero.url}
alt={product.hero.alt}
width={1600}
height={900}
sizes="(min-width: 1024px) 66vw, 100vw"
loading="eager"
fetchPriority="high"
/>
<h1>{product.name}</h1>
</article>
);
}
Reserve preload for an image that would otherwise be discovered late, and skip it when different images become the LCP element at different viewport sizes. Give fetchPriority="high" to one image per page. Marking six images as high priority is the same as marking none.
2. sizes is missing
Without sizes, the browser assumes the image will be as wide as the viewport, and Next.js generates only a small 1x/2x srcset. A card that renders at a third of the screen on desktop then downloads a file sized for the whole screen.
With sizes, Next.js generates the full width-based srcset and the browser can pick a file that fits. The value has to describe your layout, so it belongs next to the grid that defines it:
<Image
src={item.image.url}
alt={item.image.alt}
fill
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
/>
If you change the grid from three columns to four and forget sizes, nothing breaks visibly. You just ship heavier images, which is why this one is worth checking automatically.
3. Alt text is an afterthought
alt is a required prop, so TypeScript forces you to pass something. That is exactly how alt="" and alt="image" end up on product photos. The Next.js docs describe the rule well: alt text should be able to replace the image without changing the meaning of the page, and purely decorative images get an empty string.
Search engines use alt text, together with the surrounding text and the file name, to understand what an image shows. The fix is not a better prop at render time. It is making alt text part of the content model, so an editor writes it when the image is added:
export type ContentImage = {
url: string;
width: number;
height: number;
alt: string;
decorative?: boolean;
};
export function altFor(image: ContentImage): string {
if (image.decorative) return "";
if (!image.alt.trim()) {
throw new Error(`Missing alt text for ${image.url}`);
}
return image.alt;
}
Throwing works well for statically generated pages, because the build fails before a bad page ships. For pages rendered at request time, log the missing value instead and report on it.
4. The image is not in the HTML
Google does not index images set through CSS background-image. And an image that only appears after a client-side carousel has hydrated is invisible to crawlers that do not run JavaScript. If an image matters for search, such as a product photo or a diagram that explains the article, render it as a real <img> in the server response.
Checking a page in the console
Paste this into DevTools on any page. It prints the LCP element and flags images with no alt attribute, lazy loading above the fold, files much larger than their rendered size, and missing intrinsic dimensions:
new PerformanceObserver((list) => {
const lcp = list.getEntries().at(-1);
console.log("LCP:", lcp.element, lcp.url, Math.round(lcp.startTime), "ms");
}).observe({ type: "largest-contentful-paint", buffered: true });
for (const img of document.images) {
const box = img.getBoundingClientRect();
const needed = box.width * devicePixelRatio;
const issues = [];
if (!img.hasAttribute("alt")) issues.push("no alt attribute");
if (img.loading === "lazy" && box.top < innerHeight) issues.push("lazy above the fold");
if (needed > 0 && img.naturalWidth > needed * 1.5)
issues.push(`${img.naturalWidth}px file for ${Math.round(needed)}px`);
if (!img.getAttribute("width") && img.dataset.nimg !== "fill")
issues.push("no width/height");
if (issues.length) console.log(img.currentSrc, issues);
}
data-nimg="fill" is the attribute next/image sets on fill images, which are sized by their container and do not need width and height. Run the snippet at a mobile and a desktop viewport, because the LCP element and the oversized files often differ between the two.
From one page to every page
A console snippet covers one URL. Image problems usually come from templates, so the next question is which templates are affected and how much it costs. Crawl Foundry Site Audit runs the official Lighthouse runner on crawled pages, and the Lighthouse measurement itself costs nothing extra on top of the audit, so slow LCP elements show up per URL next to the rest of the technical findings. See the Site Audit feature page.
For the broader picture of formats, compression and file names, read the glossary entry on image optimization, and the entry on Core Web Vitals for the LCP thresholds.
Top comments (0)