Product images, and why they will slow you down
Images are almost always the largest thing your page downloads, and a product grid has twenty of them. On a mid-range Android on mobile data — which is your customer — getting this wrong is the difference between a shop that feels instant and one people abandon.
The scale of it
A photo from a phone is 3 to 8 MB. Twenty of those is over 100 MB for one screen of products.
Properly handled, that same grid is perhaps 400 KB. The difference is two hundredfold, and it is entirely in configuration rather than cleverness.
next/image does most of it
import Image from "next/image";
<Image
src={product.imageUrl}
alt={product.name}
fill
sizes="(min-width: 1024px) 25vw, (min-width: 640px) 33vw, 50vw"
className="object-cover"
/>
Four things happen automatically: the image is resized to what is actually
needed, converted to WebP or AVIF where supported, served lazily unless marked
priority, and its space is reserved so the layout does not shift.
sizes is the one you must get right, and the one most often wrong.
It tells the browser how wide the image will render at each breakpoint, so it can choose an appropriate file before layout has happened. Get it wrong and a phone downloads a desktop-sized image — the page still looks correct, so nobody notices except your customers' data allowance.
Read that value as: four columns on a large screen means each image is 25% of the viewport; three columns is 33%; two columns is 50%. It must match your grid's breakpoints. Change the grid and change this.
fill requires a positioned, sized parent:
<div className="relative aspect-square">
<Image fill sizes="..." />
</div>
Without relative, the image escapes its container. Without a height —
aspect-square here — it collapses to nothing. Both are common and both look
like next/image being broken when it is not.
Handling a missing image
Half your catalogue will not have a photo on day one, and src={null} throws.
// apps/web/src/components/product/product-image.tsx
import Image from "next/image";
export function ProductImage({
src,
alt,
priority = false,
sizes = "(min-width: 1024px) 25vw, (min-width: 640px) 33vw, 50vw",
}: {
src: string | null;
alt: string;
priority?: boolean;
sizes?: string;
}) {
if (!src) {
return (
<div className="relative grid aspect-square place-items-center bg-gray-100">
<span className="px-4 text-center text-xs text-gray-400">{alt}</span>
</div>
);
}
return (
<div className="relative aspect-square overflow-hidden bg-gray-50">
<Image
src={src}
alt={alt}
fill
sizes={sizes}
priority={priority}
className="object-cover"
/>
</div>
);
}
The placeholder is the same shape as a real image, so a catalogue that is half photographed does not have a ragged grid.
It shows the product name, which is more useful than a grey box or a generic icon.
priority, and using it correctly
<ProductImage src={product.imageUrl} alt={product.name} priority />
priority disables lazy loading and preloads the image. Use it on the one
image visible without scrolling — the main photo on a product page.
Do not put it on grid items. Marking twenty images priority means the browser downloads twenty at once, competing with each other and with your JavaScript, and the page gets slower. That is the opposite of what the attribute is for.
On a product list, no image needs priority. The grid is below the header and lazy loading is correct.
Where images actually live
Three options, and the choice matters more than the code.
In your repository. Fine for a handful of static assets. Wrong for a product catalogue — every image change is a deploy, and the repository grows without limit.
Object storage — S3, Cloudflare R2, Supabase Storage. Upload once, serve from a CDN. This is the answer for a real shop, and module 13's admin screens need it for uploads.
A third-party image service — Cloudinary, imgix. Storage plus transformation on the fly. Convenient, and another dependency with a bill.
For this course, object storage. Configure Next.js to allow it:
// apps/web/next.config.js
module.exports = {
images: {
remotePatterns: [
{ protocol: "https", hostname: "your-bucket.r2.dev", pathname: "/products/**" },
],
},
};
This allowlist is not bureaucracy. Without it, anyone could pass any URL through your image optimiser, making your server a free image proxy for the rest of the internet — and you would pay for the bandwidth.
What to ask of the images themselves
Configuration cannot fix a bad source image.
Square, or consistently cropped. Mixed aspect ratios in a grid look
careless. object-cover crops to square, so the subject must be centred.
Around 1000 by 1000 pixels. Enough for a product page on a high-density
screen; more is wasted, because next/image will never serve it larger.
Well lit, plain background. A phone photo on a cluttered counter reads as untrustworthy, and trust is most of what a small shop is selling online.
Under about 500 KB at source. next/image optimises on delivery, but a
50 MB source is slow to process and expensive to store.
For a kirana shop, packaged goods often have manufacturer images available. Loose items — vegetables, dal — need photographing, and doing thirty at once on a phone against a white sheet takes an afternoon.
Measuring it
Do not guess.
The Network tab, filtered to images, with throttling on Slow 4G. Look at the transferred size per image and the total. A product grid should be well under a megabyte.
Lighthouse, mobile preset. It reports oversized images, missing dimensions and layout shift directly.
The check that catches the sizes mistake: set a mobile viewport, reload,
and look at what was actually downloaded. If a 390px-wide phone fetched a
1200px image, sizes is wrong.
Layout shift
Cumulative Layout Shift: 0
next/image with fill inside a sized container gives you this for free,
because the space is reserved before the image arrives.
It matters twice over: it is a Core Web Vitals measure that affects ranking, and it is the reason a customer taps the wrong product when the page shifts under their thumb.
Check your work
What sizes does: tells the browser how wide the image will render at each
breakpoint so it can choose a file before layout. Wrong values mean a phone
downloading a desktop image, with no visible symptom.
Why fill needs relative and a height: without relative the image
escapes its container; without a height it collapses.
Where priority belongs: on the single image visible without scrolling.
Marking a whole grid priority makes the page slower.
Why the missing-image placeholder is the same shape: a half-photographed catalogue otherwise has a ragged grid.
Why remotePatterns is required: without an allowlist your optimiser
becomes a free image proxy for anyone, at your expense.
Why source images should be about 1000px: enough for a high-density product
page, and next/image will never serve larger.
How to catch a sizes mistake: load at a mobile viewport and check what was
actually downloaded.
Why layout shift matters twice: it is a ranking factor, and it makes customers tap the wrong thing.
Practice
- Add images to your seed data. Use real product photos or placeholder URLs.
- Render the grid with
next/image. Check the Network tab for transferred sizes. - Remove
sizesand compare the downloaded size at a mobile viewport. - Set
sizes="100vw"on a four-column grid and note what changes. - Remove
relativefrom the parent and watch the image escape. - Remove
aspect-squareand watch it collapse. - Add
priorityto every grid image. Measure the page before and after. - Load a product with no image and confirm the placeholder holds its shape.
- Try an image from a domain not in
remotePatterns. Read the error. - Run Lighthouse on mobile, throttled. Get layout shift to zero and note your largest contentful paint.
That is module eleven. Customers can browse, search, filter and view a product, and the pages are fast and indexable.
Next module: the cart, and the hard part nobody warns you about.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship