After reading this, you will be able to drop the Next.js Image component into a page, configure the loader and sizing behavior correctly, verify the effect on Largest Contentful Paint (LCP), and know when the component is the wrong choice for a given asset. The walkthrough below follows a single implementation path from an unoptimized <img> tag through to a tuned next/image setup, with the reasoning at each step so you can adapt it rather than copy it blindly.
What the Image Component Replaces
A plain <img> tag hands the browser one file and no context. The browser does not know the rendered width of the element, the pixel density of the display, or which formats the client supports, so it downloads the same asset for every visitor. The Next.js Image component wraps that tag and fills in the missing context: it emits a srcset of generated widths, negotiates modern formats through the built-in optimizer, lazy-loads by default, and reserves layout space from the intrinsic dimensions.
That sounds like a straight upgrade, and for most content images it is. The catch is that the component is opinionated about where your images live and how they are served, and those opinions have to match your deployment.
The Starting Point: An Unoptimized Hero
Assume a product page with a hero photograph stored in /public/images/hero.jpg. The first version of the page looks like this:
// pages/product.jsx
export default function Product({ product }) {
return (
<main>
<img src="/images/hero.jpg" alt={product.name} className="hero" />
<h1>{product.name}</h1>
</main>
);
}
The file behind /images/hero.jpg might be a 3000-pixel-wide JPEG exported from a design tool. Every visitor, on every device, downloads that exact file. A phone on a 390px viewport and a 4K monitor fetch the same bytes. LCP for the hero is bounded below by the time it takes to transfer that file, and there is no way for the browser to choose anything smaller because nothing smaller exists.
Two things are wrong, and they are independent: the file is larger than it needs to be for any given viewport, and there is no mechanism for selecting a smaller variant.
Step One: Swap in the Component
The first change is mechanical. Import Image from next/image and replace the tag, keeping width and height for the intrinsic aspect ratio:
// pages/product.jsx
import Image from 'next/image';
export default function Product({ product }) {
return (
<main>
<Image
src="/images/hero.jpg"
alt={product.name}
width={1200}
height={800}
className="hero"
/>
<h1>{product.name}</h1>
</main>
);
}
At this point the component does several things automatically. It generates a srcset with several widths derived from the intrinsic dimensions, serves them through the built-in optimizer at /_next/image, converts each to WebP or AVIF where the requesting browser advertises support, and sets loading="lazy" and decoding="async" unless told otherwise. The rendered markup, simplified, ends up close to:
<img
srcset="/_next/image?url=%2Fimages%2Fhero.jpg&w=640&q=75 640w,
/_next/image?url=%2Fimages%2Fhero.jpg&w=1080&q=75 1080w,
/_next/image?url=%2Fimages%2Fhero.jpg&w=1200&q=75 1200w"
sizes="100vw"
src="/_next/image?url=%2Fimages%2Fhero.jpg&w=1200&q=75"
loading="lazy"
decoding="async"
width="1200"
height="800"
/>
The sizes attribute defaults to 100vw, which assumes the image spans the full viewport. That default is often wrong for anything that lives in a grid or a sidebar, and a wrong sizes value makes the browser pick a larger candidate than necessary. Correcting it is the next step.
Step Two: Declare the Real Rendered Width
The sizes attribute tells the browser how wide the image will be at different viewport widths, and it is the single most commonly skipped piece of configuration. Without it, a thumbnail in a three-column grid gets sized as if it were full width, and the browser downloads a needlessly large candidate on every visit.
If the hero occupies the full viewport on narrow screens and roughly half the viewport on wider ones, the attribute becomes:
<Image
src="/images/hero.jpg"
alt={product.name}
width={1200}
height={800}
sizes="(max-width: 768px) 100vw, 50vw"
className="hero"
/>
The media-query syntax mirrors the CSS that governs the layout. When the layout changes, sizes has to change with it, and a mismatch is a silent performance regression rather than a visible bug.
Step Three: Give the LCP Image Priority
By default the component lazy-loads. That is correct for most images but wrong for the one the browser names as the LCP candidate. A lazy-loaded hero starts download only after layout, and on a slow connection that delay can land directly on your Core Web Vitals score.
Adding the priority prop opts the image out of lazy loading and emits a <link rel="preload"> in the document head:
<Image
src="/images/hero.jpg"
alt={product.name}
width={1200}
height={800}
sizes="(max-width: 768px) 100vw, 50vw"
priority
className="hero"
/>
A useful heuristic: at most one or two images per page deserve priority, and they should be the ones visible without scrolling. Everything below the fold should keep the default lazy behavior.
Step Four: Configure the Loader for Your Deployment
The built-in optimizer runs through the Next.js server, which means it needs somewhere to run. On Vercel it works with no configuration. In a static export (output: 'export'), the optimizer is unavailable entirely, because there is no server to resize images on demand. In a container deployment, the sharp package is required in production dependencies or the optimizer falls back to a slower path or fails.
For teams that host images on a CDN with its own transformation API, a custom loader replaces the built-in one:
// next.config.js
module.exports = {
images: {
loader: 'custom',
loaderFile: './cdn-loader.js',
},
};
// cdn-loader.js
export default function cdnLoader({ src, width, quality }) {
return `https://cdn.example.com/${src}?w=${width}&q=${quality || 75}`;
}
The loader receives the source path and the required width and quality, and returns a URL. When a custom loader is set, Next.js stops proxying requests through /_next/image, so the CDN’s own caching and resizing rules apply instead. This is the path to take when your CDN already handles image transformation, because cascading two resizing layers wastes CPU without improving the output.
Two related configuration keys matter here. remotePatterns controls which external hostnames the built-in optimizer is willing to fetch from, and omitting it means external images throw at build time. unoptimized: true disables the optimizer entirely and makes the component render a plain <img> — useful when the source images are already optimally sized and CDN-served, not useful as a general fix.
Step Five: Verify the Result
Configuration is only half the work. Verification is the part that turns a plausible change into a confirmed one, and the tools are the same ones used to diagnose the original problem.
Run Lighthouse against a production build with mobile throttling enabled and inspect three numbers: LCP, Total Blocking Time, and Cumulative Layout Shift. The hero’s LCP should fall measurably once priority is set and sizes reflects the layout, and CLS should be near zero because the width and height attributes reserve space before the image arrives.
Then open the Network panel with caching disabled and look at the actual requests. Each image should appear as a /_next/image request with a w parameter matching the viewport, and the response Content-Type should read image/webp or image/avif rather than image/jpeg. If the format is still JPEG, the browser did not advertise support or the optimizer is not running in the environment you are testing. Confirm which by comparing a production URL against a local dev server, since the optimizer path behaves differently in each.
Finally, check the rendered HTML for the srcset and sizes attributes. If sizes reads 100vw on an image that lives in a narrow column, the configuration is incomplete regardless of what the score says.
Where the Component Falls Short
The Image component is not the right tool everywhere, and the failure modes are worth naming.
Images rendered inside a container whose dimensions are not known until runtime — a chart, a canvas texture, a CSS background-image — cannot use the component, because it requires intrinsic dimensions. Background images fall outside its scope entirely and need a manual srcset or a CSS image-set() rule.
SVG files are typically better left as plain <img> or inline markup. The optimizer passes them through, but there is no raster resizing to gain, and routing them through the pipeline adds a request hop for no benefit.
Images that are already optimally sized and served from a CDN with its own transform layer often gain nothing from double resizing. In that case unoptimized: true together with a custom loader, or a plain <img> with a hand-written srcset, is simpler and faster.
Finally, the component adds a moving part to your deployment. Static exports lose it, container builds need sharp, and the optimizer’s cache — written to .next/cache/images on the local filesystem — does not persist across ephemeral containers unless a shared volume or a CDN layer is wired in. Teams that run many short-lived instances sometimes find the cold-start cost of regenerating variants outweighs the savings, particularly for large catalogs.
A Compact Reference
| Concern | Setting or Prop | Default Behavior |
|---|---|---|
| Responsive widths | sizes |
100vw, often too large |
| LCP priority | priority |
lazy-loaded |
| Output format | optimizer, automatic | WebP/AVIF when supported |
| External hosts | images.remotePatterns |
blocked until listed |
| Custom CDN | images.loaderFile |
built-in /_next/image |
| Disable entirely | images.unoptimized |
false |
The pattern across all of this is that the component handles the mechanical work — generating candidates, negotiating format, reserving layout space — and leaves the judgment calls to you. Which image deserves priority, what the sizes attribute should say, and whether the built-in optimizer or a CDN loader is the right layer are decisions the defaults cannot make on your behalf. Getting those three right is what separates a passing LCP score from a page that merely renders.
Have a specific image pipeline question, or unsure whether your CDN loader and the built-in optimizer should be layered or kept separate? Describe the setup and the constraint, and the trade-offs can be worked through against your actual deployment shape.
Related guides: How to Cut a React App’s Image Load Time: A Practical Guide, Improving Largest Contentful Paint (LCP) in React Apps: A Beginner vs Advanced Guide, and Common Mistakes That Slow Down Next.js Image Optimization.
🔗 Recommended Reading
- React Performance Mistakes in Fintech Dashboards and How to Fix Them
- Lazy Loading Images and Components in React: A Step-by-Step Tutorial
- React Memoization: When and How to Use memo, useMemo, and useCallback
- Next.js App Router Performance Optimization for Beginners: A Step-by-Step Guide
- Measuring and Fixing React Re-render Performance: A Step-by-Step Walkthrough