Skip to main content
Back to build log
Performance

Three Next.js Image Patterns We Default To Across Builds

Failures
1
Outcome
LCP improvements typically 200–500ms across recent builds when patterns are applied consistently
Next.js 14next/imageTailwind CSSVercelLighthouse CI
ByDOT· Founder @ DOTxLabs
Published May 7, 20267 min read
TL;DRThe image-handling defaults DOTxLabs reaches for on every Next.js 14 build — what each one solves, what it costs, and the wrong default that cost us a measurable LCP regression.

Three Next.js Image Patterns We Default To

Across recent DOTxLabs builds, three image patterns earn their keep: a priority + sizes + fetchPriority="high" config for the hero LCP image, a loading="lazy" + sizes + responsive width descriptors config for below-the-fold content, and a static-import + placeholder="blur" config for any image known at build time. Match the pattern to the image's role; defaulting them all to one config leaves measurable performance on the table.

The Next.js next/image component is excellent. Its defaults are not.

The defaults are conservative — they optimize for "won't crash, won't regress on the most-common case." They don't optimize for any specific role an image plays in a layout, and the role is what decides which knobs to turn. The three patterns below are how we configure next/image at DOTxLabs depending on what the image is for. Each one solves a measurable problem; using the wrong one creates a measurable cost.


Pattern 1 — Hero LCP image

Used for: the largest above-the-fold image on a page, the one Lighthouse picks as the LCP element.

Configuration:

import Image from "next/image";

export default function HeroSection() {
  return (
    <section className="relative h-[60vh]">
      <Image
        src="/hero/property-exterior.jpg"
        alt="Boutique hospitality property exterior at dusk"
        fill
        priority
        fetchPriority="high"
        sizes="(min-width: 1280px) 1280px, 100vw"
        className="object-cover"
      />
    </section>
  );
}

What each prop earns:

  • priority — preloads the image in the head. Removes the round-trip wait between HTML arrival and image discovery.
  • fetchPriority="high" — tells the browser this image is more important than other resources fighting for early bandwidth.
  • sizes — without this, the browser fetches the largest available variant by default. With it, the browser fetches the variant that actually fits the viewport.
  • fill + object-cover — the parent's height is the source of truth. The image fills it without us having to commit to a fixed aspect ratio.

The single most common LCP regression we see in audits is a hero image loaded without priority. Adding it is usually a 200–400ms LCP improvement in our before/after measurements, and the change is one line.


Pattern 2 — Below-the-fold content image

Used for: any image that won't appear in the first viewport — card grids, blog post inline images, footer logos.

Configuration:

<Image
  src={post.coverImage}
  alt={post.coverImageAlt}
  width={800}
  height={450}
  loading="lazy"
  sizes="(min-width: 768px) 33vw, 100vw"
  className="rounded-xl"
/>

What each prop earns:

  • loading="lazy" — the default for non-priority next/image, but worth being explicit when reviewers might think it's missing.
  • width + height — required by next/image when not using fill. They reserve layout space, preventing CLS when the image loads.
  • sizes — same as Pattern 1. Without it, mobile users on a 33vw card grid would still download a desktop-sized image.

The sizes prop is the one most often forgotten in below-the-fold images. The default is 100vw, which means a card grid showing nine images will fetch nine images sized for the full viewport width even though each one displays at one-third. We've seen builds where adding the right sizes prop cut total page weight by 30–50% with no visual change.


Pattern 3 — Build-time-known image with blur placeholder

Used for: any image where the path is known at build time — author headshots, static marketing assets, logo files.

Configuration:

import Image from "next/image";
import authorPhoto from "@/public/authors/david-ajai.jpg";

export default function AuthorByline() {
  return (
    <Image
      src={authorPhoto}
      alt="David Ajai, Founder of DOTxLabs"
      width={64}
      height={64}
      placeholder="blur"
      className="rounded-full"
    />
  );
}

What this earns:

The static import gives Next.js the file at build time. That unlocks two things:

  1. placeholder="blur" works without a manual blurDataURL — Next.js generates the LQIP automatically from the imported asset.
  2. The image's dimensions and content hash are known at build, which means stronger cache headers and fewer redirects in the served URL.

We use this everywhere we can. The reason it isn't the default in Pattern 1 or 2 is that a hero image is often CMS-driven (Pattern 1) and a content image is often dynamic (Pattern 2). When the source is known, prefer the static import.


What didn't work: one-size-fits-all defaults

Early on, we shipped a wrapper component that set sensible defaults for every image: loading="lazy", sizes="100vw", no priority ever. The thinking was DRY — let the wrapper handle it, no per-image cognitive load.

It cost us LCP on the hero of the first build that used it.

The wrapper's defaults made sense for 90% of images on the page and were exactly wrong for the one image that mattered most. The hero, which had been the LCP element, was now lazy-loaded with no priority hint, which meant the browser deprioritized it relative to fonts and CSS, which meant the LCP measurement on the new build came in 600ms slower than the previous build.

We caught it in the staging Lighthouse run before the build went live. The fix was to add a priority prop to the wrapper and remember to pass it on the hero. But the broader lesson was different: defaults should be safe, not opinionated. The wrapper was opinionated about every image being below-the-fold lazy, and that was wrong for any image that wasn't.

The current pattern at DOTxLabs is: no shared wrapper. Three explicit configurations matched to role, copy-pasted into each component. The duplication is small. The cost of getting the defaults wrong on the wrong image is large.


What we measure

Across recent builds where we applied these patterns consistently:

  • LCP improvements: typically in the 200–500ms range, depending on what the previous default was. Largest gains came from adding priority + fetchPriority to a hero that previously had neither.
  • CLS: when width/height (or fill) were missing, applying them dropped CLS into the green. CLS regressions almost always trace to images or fonts; images are the cheaper one to fix.
  • Total page weight: cutting sizes correctly on listing pages typically saves 30–50% of image bytes for mobile viewports without any visible quality difference.

We don't publish a single before/after number because the previous default on each build varied — some builds came in with no next/image at all, others had it but no sizes, others had sizes but no priority. The improvements compound differently depending on where the build started.


Where this becomes a problem

These patterns assume the image-optimization service is doing on-the-fly resizing. That's free on Vercel through the Vercel Image Optimization service. Off Vercel, you either swap in a loader (Cloudinary, imgix, the next.config.js images.loader field) or run sharp at build time and serve a static asset. The patterns above don't change. The runtime under them does.

If you're running these and not seeing the LCP improvement you expected, the first thing to check is whether the optimization service is actually running. A build that skips the service ships the original 4MB JPEG to every device, which no amount of sizes config can rescue.


Why this is in the Build Log

Because performance is a place where small repeatable defaults are worth more than one-off heroic optimizations. The win isn't a clever trick on a single page — it's three small habits applied across every page on every build, every time. That compounds.

If you're auditing a Next.js site and the Lighthouse numbers are below the green threshold, the image layer is usually the cheapest thing to fix. If you'd like a fresh pair of eyes on yours — yours, not a generic template — book a Stack Diagnostic and we'll run the audit.

Frequently asked questions

  • Why not just use next/image with default props everywhere?

    Because the default props optimize for safety, not for the specific role each image plays. A hero LCP image needs different priorities than a below-the-fold avatar — and getting either one wrong has measurable performance cost. The patterns below are about matching the configuration to the role.

  • Do these patterns work outside Vercel?

    The next/image component does. The Vercel image optimization service is what gives you on-the-fly resizing without setting up a CDN — outside Vercel, you either swap in a loader (Cloudinary, imgix, etc.) or run sharp at build time. The patterns are the same; the runtime is what changes.

  • What about CLS?

    Always set width and height — or fill — on next/image. Unset dimensions are the single most common CLS source we see in audits of GTA small-business sites. The image component will refuse to render in dev without dimensions, but if you bypass it with a regular img, you've handed yourself a CLS regression that won't show up until production.

  • When do you reach for blurhash or shimmer placeholders?

    Only when the perceived-load improvement is worth the extra weight. For a hero where the user lingers, yes. For a card grid where the user is scrolling past, the placeholder weight is a tax on time-to-interactive. We've removed shimmer from listings and seen no engagement drop.

Want this pattern in your stack?

We build production AI-first systems. If something here looked like the shape of a problem you have, let's scope it.

Book a Stack Diagnostic