The Right Way to Handle Images in Next.js 15
#nextjs
#images
#webperf
#guide
Images are often the heaviest part of a page. Next.js 15 continues to make image performance straightforward with the next/image component, but there are still important choices to get right: when to use next/image, how to size images responsively, how to configure remote sources, and how to integrate an external CDN.
This guide focuses on practical patterns you can copy into production apps.
Quick checklist
- Use next/image for content images; use plain img for very small icons or when optimization is handled by an external CDN.
- Always provide sizes for responsive images, and width/height or fill to prevent layout shift.
- For hero/LCP images, use priority and a tight sizes value.
- Prefer static imports for local assets; configure remotePatterns for third‑party sources.
- If you already use an image CDN, either use a custom loader or set images.unoptimized to avoid double optimization.
- Add meaningful alt text and never rely on filenames.
- Choose the right element
- Use next/image when:
- You want built-in resizing, format conversion, and caching.
- You can know intrinsic size (via static import) or you can render within a container and use fill.
- Use img when:
- The image is a tiny icon or purely decorative and already optimized.
- You are fully delegating optimization to an external CDN and do not need Next’s processing.
- Use CSS background-image when:
- The image is decorative and not semantic page content.
- Local images via static import Static imports give you automatic width/height and a blur placeholder without extra work.
Example: a responsive hero image with proper LCP treatment.
// app/page.tsx
import Image from "next/image";
import hero from "@/public/hero.jpg";
export default function Home() {
return (
<section>
<div
style={{
position: "relative",
width: "100%",
height: "60vh",
minHeight: 420,
}}
>
<Image
src={hero}
alt="Product detail on a wooden desk"
fill
priority
placeholder="blur"
sizes="100vw"
style={{ objectFit: "cover" }}
/>
</div>
</section>
);
}
Notes
- fill removes the need to set width/height but requires a sized container to avoid layout shift.
- priority preloads the image and sets a high fetch priority for faster LCP.
- sizes=“100vw” ensures the server generates the right srcset and the browser picks the closest width for the viewport.
- Remote images: allow-listing and security For external sources, allow only what you need with remotePatterns.
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{ protocol: "https", hostname: "images.example-cms.com", pathname: "/**" },
{ protocol: "https", hostname: "res.cloudinary.com", pathname: "/my-space/**" },
],
formats: ["image/avif", "image/webp"], // modern formats
},
};
module.exports = nextConfig;
Usage:
import Image from "next/image";
export function ArticleCover({ url, alt }: { url: string; alt: string }) {
return (
<Image
src={url}
alt={alt}
width={1200}
height={630}
sizes="(max-width: 768px) 100vw, 1200px"
placeholder="empty"
/>
);
}
If you do not know the intrinsic size from the CMS, either:
- Use fill with a wrapper that enforces aspect-ratio.
- Or fetch dimensions server-side and pass width/height to avoid layout shift.
- Responsive images done right The sizes attribute is essential. It tells the browser how much space the image will occupy so it can choose the best candidate from the srcset. Without sizes, the browser may download unnecessarily large files.
Common patterns
- Full-bleed hero: sizes=“100vw”
- Two-column content: sizes=“(max-width: 1024px) 100vw, 50vw”
- Card grid (min 300px columns): sizes=“(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw”
Cards example:
// app/components/ProductCard.tsx
import Image from "next/image";
export function ProductCard({ img, alt }: { img: string; alt: string }) {
return (
<article className="card">
<div className="media">
<Image
src={img}
alt={alt}
width={600}
height={400}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
style={{ width: "100%", height: "auto" }}
/>
</div>
{/* ... */}
</article>
);
}
- Prevent layout shift
- Provide width and height when possible; next/image will reserve the correct space.
- If using fill, ensure the parent has a fixed height or an aspect-ratio. For example:
<div style={{ position: "relative", aspectRatio: "16 / 9" }}>
<Image src={src} alt={alt} fill sizes="(max-width: 768px) 100vw, 50vw" />
</div>
- LCP and preloading
- Use priority on the single most important image above the fold. Avoid using it on more than a couple of images per page.
- Provide a tight sizes value for the hero so the browser does not choose needlessly large variants.
- Optionally preconnect to the remote image origin for faster TTFB:
// app/layout.tsx
export const metadata = {
other: {
// Next will emit these in <head>
link: [
{ rel: "preconnect", href: "https://images.example-cms.com", crossOrigin: "" },
],
},
};
- Placeholders: blur and custom shimmer
- For static imports, placeholder=“blur” is automatic.
- For remote images, provide blurDataURL manually or generate one at build/request time.
Minimal custom shimmer:
function shimmer(w: number, h: number) {
return `data:image/svg+xml;base64,${Buffer.from(
`<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="g"><stop stop-color="#eee" offset="20%"/><stop stop-color="#ddd" offset="50%"/><stop stop-color="#eee" offset="70%"/></linearGradient></defs>
<rect width="${w}" height="${h}" fill="#eee"/><rect id="r" width="${w}" height="${h}" fill="url(#g)"/>
<animate xlink:href="#r" attributeName="x" from="-${w}" to="${w}" dur="1s" repeatCount="indefinite" />
</svg>`
).toString("base64")}`;
}
<Image
src={url}
alt={alt}
width={800}
height={450}
placeholder="blur"
blurDataURL={shimmer(800, 450)}
/>
- Using an external image CDN (Cloudinary, Imgix, Akamai) If an external CDN already resizes and optimizes images, you have two safe options:
A) Custom loader (keeps next/image layout, lets external CDN transform)
// lib/cloudinaryLoader.ts
export default function cloudinaryLoader({ src, width, quality }: { src: string; width: number; quality?: number }) {
const q = quality || 75;
return `https://res.cloudinary.com/my-space/image/fetch/w_${width},q_${q},f_auto/${encodeURIComponent(src)}`;
}
// usage
import Image from "next/image";
import cloudinaryLoader from "@/lib/cloudinaryLoader";
<Image
loader={cloudinaryLoader}
src="https://images.example-cms.com/cover.jpg"
alt="Cover"
width={1200}
height={630}
sizes="(max-width: 768px) 100vw, 1200px"
/>
B) Disable Next optimization entirely and use plain img where needed
// next.config.js
module.exports = {
images: { unoptimized: true },
};
Use this only if your CDN or HTML already sets responsive srcset and sizes.
- Static export projects If you build with output: “export”, Next’s on-demand Image Optimization is not available at runtime.
- Either set images.unoptimized: true and rely on your CDN, or
- Preprocess images in your pipeline, or
- Deploy to a platform that supports Next’s image optimization function.
- MDX/CMS content: mapping to next/image Render CMS/MDX images with next/image via a component map so you keep optimization and sizing consistent.
// app/providers.tsx
import Image from "next/image";
import type { MDXComponents } from "mdx/types";
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
img: (props) => (
<Image
{...props}
alt={props.alt || ""}
sizes="(max-width: 768px) 100vw, 800px"
width={Number(props.width) || 800}
height={Number(props.height) || 0}
style={{ height: "auto" }}
/>
),
...components,
};
}
If your CMS does not provide width/height, wrap with a container and use fill plus an aspect-ratio.
- Caching and headers
- Next caches optimized images on the server and sets long max-age with immutable for the variants. Remote images respect images.minimumCacheTTL if needed.
- Avoid changing image URLs without cache-busting if you expect updates; use content hashes or versioned paths.
- When sitting behind a CDN, let it cache the optimized variants; do not strip caching headers.
Optional tuning:
// next.config.js
module.exports = {
images: {
minimumCacheTTL: 60 * 60 * 24 * 30, // 30 days
},
};
- Accessibility and quality
- Always provide descriptive alt text. Use empty alt="" only for purely decorative images.
- Keep quality conservative; the default ~75 is a good balance. Only raise it for brand assets where artifacts are visible.
- Prefer AVIF or WebP output; fall back to JPEG/PNG automatically.
- Common pitfalls
- Missing sizes with fill leads to oversize downloads. Always set sizes.
- Using priority on many images hurts bandwidth and LCP. Limit it to the primary hero.
- Remote images failing to render often stem from missing remotePatterns or blocked hosts.
- Layout shift occurs when width/height are omitted without fill and a sized container.
A pragmatic next.config.js template
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [360, 414, 640, 768, 1024, 1280, 1536, 1920],
imageSizes: [16, 32, 48, 96, 128, 256, 384, 640],
remotePatterns: [
{ protocol: "https", hostname: "images.example-cms.com", pathname: "/**" },
{ protocol: "https", hostname: "res.cloudinary.com", pathname: "/my-space/**" },
],
// minimumCacheTTL: 2592000, // 30 days if you want a floor
},
};
module.exports = nextConfig;
Conclusion
- Use next/image for most content images, and be deliberate with sizes and layout to avoid layout shift and wasted bytes.
- Configure remote sources explicitly and choose between Next optimization and your image CDN—do not double-optimize.
- Treat the hero image as a first-class citizen with priority and a tight sizes rule.
- Bake accessibility and caching into your default components so every page benefits.