Skip to main content
ASoc
Tutorial

Next.js Image Optimization Without next/image: 255 KiB to 33 KiB

A closed image set does not need a request-time optimiser. The sharp build script, the plain-img markup, and the lazy LCP image that cost us 1.67s of load delay.

The ASoc Team13 min read

If every image on your site is known at build time, you do not need a request-time image optimiser. Resize and re-encode them with a build script, commit the derivatives, and serve them from plain <img> tags with explicit dimensions and a srcset. You keep the bytes, lose the per-image cost, and give up nothing except optimising images you do not have yet.

This is the path we took on a storefront with 111 products and 892 source images. The numbers below are from that migration, measured with Lighthouse against a local production build. It is not the default advice, and the last section explains exactly when next/image remains the better tool — for most sites, it does.

The default is good. Here is what it costs.

next/image earns its reputation. It generates responsive srcsets, converts to modern formats, lazy-loads below the fold, and reserves layout space so nothing shifts. On a site whose images arrive from a CMS or from users, it is the right answer and nothing here applies.

Its model is transform on first request, then cache. That model has three consequences worth naming:

  1. The first visitor to each image size pays for the transform. Cold caches happen after deploys, on new routes, and at new breakpoints.
  2. On managed hosting it is a metered line item. Image optimisation is billed per source image on Vercel, and a large catalogue can make it a real number.
  3. Self-hosting means running sharp in your server runtime, which is a dependency and a CPU profile you now own.

None of that is a flaw. It is the price of handling images you have not seen yet. The question this post asks is what happens when you have seen them all.

The test: is your image set closed?

Build-time variants are the better trade when all three are true:

ConditionWhy it matters
Every image exists in the repo at build timeNothing to transform at request time
The rendered sizes are known and fewYou can enumerate the derivatives
Images change on a deploy, not per requestThe cache never needs to be per-request

Marketing sites, documentation, catalogues generated from a typed data file, portfolios — these are closed image sets. A user-avatar upload, a CMS with editors adding images hourly, or anything user-generated is an open set, and open sets want an optimiser.

Our storefront is closed: every product screenshot is committed next to the catalogue entry that references it.

What the migration actually changed

Before: covers were 2120×1325 JPEGs served into a card painted at 530×330. The home page shipped 716 KiB of images and the templates grid shipped 14.7 MB.

AssetBeforeAfterHow
Product cover in a card255 KiB33 KiBPre-built 1060w WebP derivative
public/images total117 MB93 MB440 gallery JPEGs converted to WebP
Site logo70 KiB9 KiB681×239 → 272×95; it shipped on every page, twice
Hero art87 KiB45 KiBRe-encode, plus an 800w srcset alternate
19 marketing images1793 KiB550 KiBWebP conversion

Two findings from that table are worth stealing:

  • The logo mattered more than any single photograph. It was on every page, in the header and the footer, at eight times the resolution it rendered at. Audit the assets that appear everywhere before the ones that are individually large.
  • Several use-cases/*.png files were JPEGs wearing a .png extension. Check the actual encoding, not the filename; a build script that reads real headers finds these for free.

The end state: desktop Lighthouse performance 100, accessibility 100, SEO 100 across eight page types, with CLS 0 everywhere.

The build script

One script, sharp, and a convention. Ours emits two widths per cover because we render covers at exactly two sizes:

// scripts/images/build-variants.ts
import { existsSync, statSync } from "node:fs";
import sharp from "sharp";

/** Cards paint 530px CSS wide; at DPR 2 that is 1060. */
export const CARD_VARIANT_WIDTH = 1060;
/** The full-bleed 16:9 frame on a detail page. */
export const VIEW_VARIANT_WIDTH = 1600;

/**
 * q78 is where these screenshots stop paying for quality: below it the flat
 * UI fills start banding, above it the file grows with no visible return.
 */
const QUALITY = 78;

async function emit(src: string, out: string, width: number) {
  // Idempotent: skip a derivative already newer than its source, so a rebuild
  // after adding one product does not re-encode 900 files.
  if (existsSync(out) && statSync(out).mtimeMs >= statSync(src).mtimeMs) return;

  await sharp(src)
    .resize({ width, withoutEnlargement: true })
    .webp({ quality: QUALITY, effort: 5 })
    .toFile(out);
}

Three decisions in there are the ones that make it maintainable:

  • withoutEnlargement: true. Without it, a source smaller than the target gets upscaled into a bigger file that looks worse. Silent, and easy to miss.
  • Idempotence via mtime. A full re-encode of a large catalogue is minutes. Skipping fresh derivatives makes the script something you run casually rather than avoid.
  • Quality picked by looking, once, and then written down. quality: 78 with a comment explaining why stops the number drifting every time someone new touches the file.

Resolving the derivative path is a pure function, which keeps the components dumb:

// src/lib/imageVariants.ts
const RASTER = /\.(?:jpe?g|png|webp)$/i;

const variant = (src: string, suffix: string) =>
  RASTER.test(src) ? src.replace(RASTER, `-${suffix}.webp`) : src;

/** Card-sized cover — product grids, related rails, dashboard thumbnails. */
export const cardImage = (src: string) => variant(src, "card");
/** Detail-page-sized cover. */
export const viewImage = (src: string) => variant(src, "view");

The RASTER guard matters: our placeholder cover is an SVG, which is already resolution-independent and must not be handed to sharp.

Keep the original when it has a second job

This is the constraint that shapes the whole design, and it is easy to miss.

A product's cover image has two unrelated jobs. On screen it is a thumbnail that wants to be 33 KiB. As the og:image and the image in Product structured data, it wants to be a large, universally-decodable JPEG — social crawlers and rich-result pipelines are not the place to be clever about formats.

So the source JPEG is never touched. The derivatives sit beside it, and nothing renders screenshots[0] directly:

public/images/templates/asoc-mode-shop/
  cover.jpg          116 KiB   ← og:image + Product schema. Untouched.
  cover-card.webp     26 KiB   ← every card
  cover-view.webp     32 KiB   ← the detail frame

If one file has two consumers with opposite requirements, stop trying to satisfy both and generate a second file.

The markup

Plain <img>, with everything the browser needs to do its job:

<img
  alt={product.name}
  className="absolute inset-0 h-full w-full object-cover"
  decoding="async"
  fetchPriority={priority ? "high" : undefined}
  height={330}
  loading={priority ? "eager" : "lazy"}
  src={cardImage(product.screenshots[0])}
  width={530}
/>

Every attribute is load-bearing:

  • width and height reserve the box before the bytes arrive. This is what keeps CLS at 0, and it is the one you cannot skip.
  • loading="lazy" on everything below the fold — and eager on exactly one image, which is the next section.
  • fetchPriority="high" on that same one, so it wins the bandwidth race against fonts and scripts.
  • decoding="async" keeps decode off the main thread.
  • A real alt describing the image, not "cover image".

Where a single image serves genuinely different widths — a hero that is 1600px on a desktop and ~330px on a phone — add a srcset and let the browser choose:

<img
  src="/images/hero-desktop.webp"
  srcSet="/images/hero-800.webp 800w, /images/hero-desktop.webp 1600w"
  sizes="(max-width: 768px) 100vw, 800px"
  width={1600}
  height={900}
  alt="The ASoc template catalogue on a laptop and a phone"
  fetchPriority="high"
/>

Our hero was 46 KiB, of which about 43 were wasted on a phone rendering it at a third the width. One extra derivative and a sizes attribute fixed it. This is the bit next/image does for you, and doing it by hand is genuinely more work — which is the honest cost of this approach.

The bug that cost more than every byte we saved

Worth its own section, because it is the most expensive mistake available here and it is invisible in code review.

The largest image on our /templates grid — the LCP element — was loading="lazy". A lazy image cannot be discovered by the browser's preload scanner; it waits for layout to run to find out whether it is in the viewport. That delay measured 1.67 seconds of load delay, and mobile performance on that page sat at 85 because of it.

The fix is one prop, and one rule:

The first image of a page-opening grid is eager. Every other image is lazy.

Not two. Not "the images above the fold." Marking six images priority preloads six images that then compete for bandwidth, and LCP gets worse than marking none. One image, chosen because it is the LCP element, verified by opening the Lighthouse trace and reading which element Lighthouse says it is.

After the fix, that page went from 85 to 92 on mobile.

The parts you give up

State these honestly before choosing:

You loseConsequenceMitigation
Automatic srcset per breakpointYou write srcset/sizes by hand where it mattersOnly the hero usually needs it
Blur placeholdersNo progressive revealReserve the box; CLS is 0 either way
AVIF alongside WebPSlightly larger files than best-caseAdd an AVIF pass to the script if it pays
Any handling of images not in the repoUploads and CMS images are unsupportedUse next/image for those routes
A lint rule on your side@next/next/no-img-element fires on every tagSet it to warn, deliberately, with a comment

You also take on a step someone can forget. Guard it in CI rather than in a README — our test suite asserts that every derivative a component can ask for actually exists on disk, so a new product without built variants fails npm test instead of shipping a broken image:

it("every product cover has its rendered variants", () => {
  for (const product of catalog) {
    const src = product.screenshots[0];
    if (!hasVariants(src)) continue;
    for (const path of [cardImage(src), viewImage(src)]) {
      expect(existsSync(join("public", path)), `${product.slug}: ${path}`).toBe(true);
    }
  }
});

When to keep next/image

Most of the time. Specifically:

  • User-uploaded or CMS images. Open image sets are exactly what the optimiser is for.
  • Remote images from domains you configure. Not something a build script can pre-fetch sensibly.
  • Many rendered sizes across many breakpoints. Hand-written srcsets stop being maintainable somewhere around the third size.
  • You would rather not own a build step. A perfectly good reason. The default is well-engineered and the cost is often small.

next/image and a build pipeline also coexist without conflict — pre-built variants for the catalogue, the component for anything dynamic. You are choosing per route, not for the whole application.

Our SaaS landing page guide uses next/image with priority on the hero, and that advice still stands for a site with a handful of images and no build script. The pipeline here earns its keep at 892 images, not at nine. If you are weighing the wider "how much JavaScript and how many bytes does this framework cost me" question, Astro vs Next.js has the measurements.

Mistakes and how they show up

MistakeSymptomFix
LCP image marked loading="lazy"Seconds of load delay; low mobile scoreEager + fetchPriority="high" on exactly one
priority on many imagesLCP gets worse than with noneOne image per page
No width/heightLayout jumps as images arriveAlways both; CLS goes to 0
Upscaling small sourcesBigger file, visibly worsewithoutEnlargement: true
Optimising the og:imageSocial cards break or look wrongKeep a large JPEG for crawlers
Trusting file extensionsA .png that is really a JPEGRead headers in the script
No CI guard on derivativesA new product ships a broken imageAssert existence in the test suite

Frequently asked questions

Is next/image slower than a plain <img>? Not once its cache is warm — it serves a static, correctly-sized file just like yours. The differences are the first-request transform, the per-image cost on managed hosting, and a small amount of client JavaScript. For a closed image set you can pay those costs once at build time; for an open one, you cannot.

Does using <img> hurt SEO or Core Web Vitals? No. Core Web Vitals measure what the browser does: bytes, discovery timing, and layout stability. A correctly sized WebP with width, height and the right loading attribute is indistinguishable to the browser from one an optimiser produced. Our pages score SEO 100 and CLS 0 with plain tags throughout.

Should I use WebP or AVIF? WebP is universally supported and encodes fast, which makes it the safe default for a build pipeline. AVIF is typically 15–25% smaller for photographic content but slower to encode and slower to decode on low-end devices. Add an AVIF pass with a <picture> fallback only if you measure the saving and it matters for your images — for flat UI screenshots like ours, it does not.

How do I stop someone forgetting to run the script? Make it a test, not a note. Asserting that every referenced derivative exists turns a forgotten build step into a red CI run instead of a broken image in production. Running the script in the build is also possible, but a slow build tempts people to skip it.

What about unoptimized on next/image instead of <img>? That works and keeps the component API, but you are then using a component whose main job is disabled while still shipping its client-side behaviour. If you have already committed correctly sized files, a plain tag is the simpler thing that does exactly what you want.

Templates this pipeline came from

Every template in our catalogue is rendered through this pipeline, so the image handling above is what you get, not a blog-post idealisation.

ASoc Folio is a personal portfolio with a filterable work grid — the image-heaviest layout we ship, and the one where lazy-loading discipline matters most. ASoc Haven is a real-estate site with a filterable listings grid, agent profiles and a valuation lead form. ASoc Fade is a barbershop site with a services grid, a work portfolio and a small grooming shop.

Browse the full set of Next.js landing page templates, or the Tailwind landing page templates if you would rather bring your own framework.

Keep reading

Tutorial8 min read

Next.js Intercepting Routes: Why This Codebase Has Zero

Intercepting routes mask navigation to a route in your own app. This storefront's live-preview modal shows a cross-origin iframe instead — a real reason the pattern never fit here.

Read more