DEV Community

Cover image for Zero-CLS Images in Next.js 16: LQIP Blur-Up Done Right
Mark Yu
Mark Yu

Posted on

Zero-CLS Images in Next.js 16: LQIP Blur-Up Done Right

You scroll, start reading the headline, and then the hero image finally loads.

Boom.

The whole paragraph gets shoved down half a screen.

You lose your place, your brain sighs, and your website suddenly feels like it was assembled with duct tape and hope.

On slow connections, there is another version of the same pain: the layout stays still, but the image goes from a dead gray box to a sharp photo with a harsh visual pop. No easing. No warmth. Just vibes getting absolutely tackled.

I have shipped both bugs before.

They look related, but they are actually two different image-loading problems:

  1. Layout shift
    The browser did not know how much space to reserve before the image loaded.

  2. Abrupt image pop-in
    The space was reserved, but the transition from placeholder to final image felt rough.

The first one is a correctness issue.
The second one is a polish issue.

In this post, I’ll show how I handle both in Next.js 16, using next/image, LQIP blur-up placeholders, and remote image data from a real app-style setup with React 19, Tailwind v4, and Supabase.


Why This Matters for Core Web Vitals

Layout shift is measured by Cumulative Layout Shift, usually called CLS.

Google’s guidance is simple: a good CLS score is 0.1 or less, measured at the 75th percentile of page visits. Anything above 0.25 is considered poor.

A single unreserved hero image can blow through that budget by itself because CLS is based on:

  • how much visible content moved
  • how far it moved
  • how unexpected the movement was

The image “pop” is a different problem. That one is more closely tied to perceived loading quality and often affects your Largest Contentful Paint, or LCP, especially when the image is your above-the-fold hero.

You cannot make a large remote image magically arrive instantly. Sadly, npm install faster-internet is still not real.

But you can make the wait feel intentional.

That is the job of a Low-Quality Image Placeholder, or LQIP.

Reserving space is correctness.
Blur-up is polish.
Do the correctness part even if you skip the polish.


Reserve the Image Box Before the Image Loads

The root cause of image-related CLS is simple:

The browser does not know the final image dimensions early enough, so it cannot reserve the correct space.

The fix is also simple:

Give the browser enough information to calculate the image’s aspect ratio before the image bytes arrive.

With next/image, that usually means passing the intrinsic width and height.

import Image from "next/image";

export function Figure({ src, alt }: { src: string; alt: string }) {
  return (
    <Image
      src={src}
      alt={alt}
      width={1600}
      height={900}
      sizes="(max-width: 768px) 100vw, 768px"
      style={{ width: "100%", height: "auto" }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

The important part is this pair:

width={1600}
height={900}
Enter fullscreen mode Exit fullscreen mode

That tells the browser the image has a 16:9 aspect ratio.

Then this part makes it responsive:

style={{ width: "100%", height: "auto" }}
Enter fullscreen mode Exit fullscreen mode

Do not forget height: "auto".

If you set a responsive width but accidentally force the height, you can fight the intrinsic aspect ratio and introduce weird stretching or layout instability.

Not very slay.


When You Do Not Know the Image Dimensions

Sometimes you do not have reliable dimensions available.

Common examples:

  • user uploads
  • CMS images
  • Supabase Storage images
  • S3 images
  • third-party remote URLs

In those cases, fill can be a better fit.

But with fill, the image does not reserve space by itself. The parent container owns the layout.

import Image from "next/image";

export function FillImage({ src, alt }: { src: string; alt: string }) {
  return (
    <div style={{ position: "relative", aspectRatio: "16 / 9" }}>
      <Image
        src={src}
        alt={alt}
        fill
        sizes="(max-width: 768px) 100vw, 768px"
        style={{ objectFit: "cover" }}
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

For fill, the parent needs:

position: "relative"
Enter fullscreen mode Exit fullscreen mode

And to prevent CLS, the parent also needs a reserved shape:

aspectRatio: "16 / 9"
Enter fullscreen mode Exit fullscreen mode

So your two stable layout patterns are:

<Image width={1600} height={900} />
Enter fullscreen mode Exit fullscreen mode

or:

<div style={{ position: "relative", aspectRatio: "16 / 9" }}>
  <Image fill />
</div>
Enter fullscreen mode Exit fullscreen mode

Both can produce zero image-related CLS when used correctly.

Now we can talk about the blur.


Static Imports Get Blur Automatically. Remote Images Do Not.

This is the part that trips up a lot of people.

In Next.js, placeholder="blur" behaves differently depending on where the image comes from.

Image source Automatic blurDataURL? What you do
Static import Yes Add placeholder="blur"
Remote URL No Generate and pass blurDataURL yourself
Dynamic CMS/Supabase/S3 URL No Store and render your own blurDataURL

For a bundled local image, this is enough:

import Image from "next/image";
import hero from "@/assets/hero.jpg";

export function Hero() {
  return (
    <Image
      src={hero}
      alt="Studio desk at dusk"
      placeholder="blur"
      sizes="100vw"
      style={{ width: "100%", height: "auto" }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Because hero is a static import, Next.js can inspect the image at build time and generate the blur data automatically.

But real apps usually do not live in static-import paradise.

Real apps have:

  • user uploads
  • remote image URLs
  • database-driven content
  • CMS assets
  • product images
  • avatars
  • galleries

For those, Next.js does not see the image file at build time.

So if you want blur-up for remote images, you need to generate the LQIP yourself.

The cleanest time to do that is usually when the image is uploaded.


Generate a Tiny LQIP at Upload Time

A LQIP is just a very small version of the image, usually encoded as a base64 data URL.

The image can be tiny — around 10 to 20 pixels on the long edge — because next/image will enlarge and blur it anyway.

Here is a browser-side helper that uses a canvas to generate a small base64 JPEG:

// lib/lqip.ts

/**
 * Downscale an image File into a tiny base64 LQIP.
 *
 * The result is designed for next/image's blurDataURL prop.
 */
export async function makeLqip(file: File, maxEdge = 16): Promise<string> {
  const bitmap = await createImageBitmap(file);

  const scale = maxEdge / Math.max(bitmap.width, bitmap.height);
  const width = Math.max(1, Math.round(bitmap.width * scale));
  const height = Math.max(1, Math.round(bitmap.height * scale));

  const canvas = document.createElement("canvas");
  canvas.width = width;
  canvas.height = height;

  const ctx = canvas.getContext("2d");

  if (!ctx) {
    throw new Error("2D canvas context unavailable");
  }

  ctx.drawImage(bitmap, 0, 0, width, height);

  bitmap.close();

  /**
   * Low JPEG quality is fine here.
   * The image will be blurred, so compression artifacts are not visible.
   */
  return canvas.toDataURL("image/jpeg", 0.5);
}
Enter fullscreen mode Exit fullscreen mode

A few details matter here.

First, createImageBitmap() is nicer than manually creating an <img> element and waiting for onload.

Second, keep the output tiny.

For a blur placeholder, you are not trying to preserve detail. You are trying to preserve the rough color composition while the real image loads.

You can sanity-check the output size like this:

const lqip = await makeLqip(file);

console.log(`${(lqip.length / 1024).toFixed(2)} KB`);
Enter fullscreen mode Exit fullscreen mode

For a 16px JPEG placeholder, I usually expect the string to be comfortably under 1 KB.

If your placeholder is multiple kilobytes, it is too big. At that point, the placeholder starts becoming its own performance problem, which is very “we fixed the bug by adding a new bug.”


Store the LQIP Beside the Image

The placeholder belongs with the same row that owns the image.

If you are using Supabase/Postgres, you can add a nullable column:

alter table public.photos
  add column blur_data_url text;
Enter fullscreen mode Exit fullscreen mode

I also like adding a constraint so an accidentally huge placeholder cannot sneak into the database:

alter table public.photos
  add constraint blur_data_url_len check (
    blur_data_url is null or char_length(blur_data_url) <= 2048
  );
Enter fullscreen mode Exit fullscreen mode

This keeps the column flexible but protected.

Nullable matters because older images may not have placeholders yet. Your UI should degrade gracefully instead of crashing because one legacy row is missing blur data.

You should also store the image’s natural dimensions:

alter table public.photos
  add column width integer,
  add column height integer;
Enter fullscreen mode Exit fullscreen mode

Then, during upload:

const blurDataURL = await makeLqip(file);

const { error } = await supabase.from("photos").insert({
  storage_path: path,
  width: naturalWidth,
  height: naturalHeight,
  blur_data_url: blurDataURL,
});

if (error) {
  throw error;
}
Enter fullscreen mode Exit fullscreen mode

Now each image row has the three things your frontend needs:

{
  width: number;
  height: number;
  blur_data_url: string | null;
}
Enter fullscreen mode Exit fullscreen mode

The dimensions prevent CLS.

The blur data improves perceived loading.

Separate responsibilities. Clean mental model.


Render Remote Images Safely

Now the payoff.

When you render the image, only enable placeholder="blur" if a valid blurDataURL exists.

import Image from "next/image";

type Photo = {
  url: string;
  alt: string;
  width: number;
  height: number;
  blurDataURL: string | null;
};

export function PhotoCard({
  url,
  alt,
  width,
  height,
  blurDataURL,
}: Photo) {
  return (
    <div
      style={{
        position: "relative",
        aspectRatio: `${width} / ${height}`,
      }}
    >
      <Image
        src={url}
        alt={alt}
        fill
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
        placeholder={blurDataURL ? "blur" : undefined}
        blurDataURL={blurDataURL ?? undefined}
        style={{ objectFit: "cover" }}
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This conditional is important:

placeholder={blurDataURL ? "blur" : undefined}
blurDataURL={blurDataURL ?? undefined}
Enter fullscreen mode Exit fullscreen mode

Do not do this blindly:

placeholder="blur"
Enter fullscreen mode Exit fullscreen mode

If the image is remote and blurDataURL is missing, you are asking Next.js to blur something that does not exist.

The safe version means:

  • images with LQIP get blur-up
  • old rows without LQIP still load normally
  • layout remains stable either way

That last point is the real win.

The parent’s aspectRatio reserves the space, so your CLS stays stable whether the blur placeholder exists or not.

Again:

CLS prevention should not depend on LQIP.
LQIP is visual polish, not layout correctness.


Do Not Skip sizes

This is the sneaky performance footgun.

When using responsive images, sizes tells the browser how wide the image will be at different viewport widths.

For example:

sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
Enter fullscreen mode Exit fullscreen mode

This means:

  • phones: image is full viewport width
  • tablets/small laptops: image is half viewport width
  • desktop: image is one-third viewport width

That matches a common responsive gallery layout.

If you skip sizes, the browser may assume the image renders at 100vw. That means a small grid thumbnail can accidentally download a much larger image than needed.

Your page still works, but it wastes bandwidth.

Classic “it works on my machine” energy.

For a single article image, this might be fine:

sizes="(max-width: 768px) 100vw, 768px"
Enter fullscreen mode Exit fullscreen mode

For a three-column gallery, this is better:

sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
Enter fullscreen mode Exit fullscreen mode

Match sizes to the actual rendered layout.

That is the rule.


The LCP Image: Use preload, Not priority

In older Next.js projects, you might see this:

<Image priority />
Enter fullscreen mode Exit fullscreen mode

In Next.js 16, priority is deprecated in favor of preload.

For your above-the-fold hero image, use:

import Image from "next/image";
import hero from "@/assets/hero.jpg";

export function HeroImage() {
  return (
    <Image
      src={hero}
      alt="Studio desk at dusk"
      placeholder="blur"
      preload
      sizes="100vw"
      style={{ width: "100%", height: "auto" }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

preload tells Next.js to inject a preload link so the browser can start fetching the image earlier.

Use this carefully.

You usually want it on one image: the image most likely to be your LCP element.

Do not put preload on every image in a gallery.

That is like inviting 30 people through one doorway at the same time and wondering why nobody is moving.

For below-the-fold images, let lazy loading do its job.


One More Next.js 16 Detail: Image Qualities

In Next.js 16, if you use custom image quality values, make sure your next.config.ts allows them.

For example, if you pass:

<Image quality={90} />
Enter fullscreen mode Exit fullscreen mode

Then your config should include that quality:

// next.config.ts

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    qualities: [75, 90],
  },
};

export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

If you do not need custom quality values, the default is fine.

Most of the time, I leave images at the default unless I have a specific reason to change them.


Full Example: Remote Supabase Image With Zero CLS and Blur-Up

Here is the complete pattern in one place.

import Image from "next/image";

type Photo = {
  id: string;
  url: string;
  alt: string | null;
  width: number;
  height: number;
  blurDataURL: string | null;
};

type PhotoGridProps = {
  photos: Photo[];
};

export function PhotoGrid({ photos }: PhotoGridProps) {
  return (
    <section className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
      {photos.map((photo) => (
        <article key={photo.id} className="overflow-hidden rounded-2xl">
          <div
            className="relative w-full"
            style={{
              aspectRatio: `${photo.width} / ${photo.height}`,
            }}
          >
            <Image
              src={photo.url}
              alt={photo.alt ?? ""}
              fill
              sizes="(max-width: 768px) 100vw, (max-width: 1280px) 50vw, 33vw"
              placeholder={photo.blurDataURL ? "blur" : undefined}
              blurDataURL={photo.blurDataURL ?? undefined}
              className="object-cover"
            />
          </div>
        </article>
      ))}
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

This gives you:

  • reserved layout space
  • responsive image loading
  • optional blur-up
  • safe fallback for old rows
  • no dependency on static imports
  • no server-side image processing required

Very clean. Very adult. Very “I actually care about performance.”


Testing It

After implementing this, test it like a user on a cursed coffee shop Wi-Fi connection.

Open Chrome DevTools:

  1. Go to the Network tab.
  2. Enable throttling.
  3. Pick Slow 4G.
  4. Reload the page.
  5. Watch the image areas before the final images load.

You want to see:

  • the layout stays still
  • image boxes are reserved immediately
  • blur placeholders appear quickly
  • final images fade in without a harsh visual jump

Then check Lighthouse or your real-user monitoring data for CLS.

The goal is not just passing a synthetic test. The goal is making the page feel stable while real humans use it.


Common Mistakes

Mistake 1: Using placeholder="blur" on remote images without blurDataURL

This will not work reliably.

Remote images need your own placeholder.

placeholder={blurDataURL ? "blur" : undefined}
blurDataURL={blurDataURL ?? undefined}
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Thinking LQIP fixes CLS

It does not.

LQIP improves the loading transition.

Dimensions or aspect-ratio prevent layout shift.

Different bugs. Different fixes.


Mistake 3: Using fill without reserving parent space

This is unstable:

<div style={{ position: "relative" }}>
  <Image fill src={src} alt={alt} />
</div>
Enter fullscreen mode Exit fullscreen mode

This is stable:

<div style={{ position: "relative", aspectRatio: "16 / 9" }}>
  <Image fill src={src} alt={alt} />
</div>
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Skipping sizes

If the image is responsive, write a real sizes string.

Otherwise, the browser may download bigger images than necessary.


Mistake 5: Preloading too many images

Only preload the likely LCP image.

Everything else can lazy-load.

Preloading a whole gallery is not optimization. It is network chaos in a nice jacket.


The Takeaway

Image loading has two separate problems:

  1. The page shifts
  2. The image appears too abruptly

Fix the first one by reserving layout space.

Use either:

<Image width={1600} height={900} />
Enter fullscreen mode Exit fullscreen mode

or:

<div style={{ position: "relative", aspectRatio: "16 / 9" }}>
  <Image fill />
</div>
Enter fullscreen mode Exit fullscreen mode

Fix the second one with a tiny LQIP blur placeholder.

For remote images, generate that placeholder yourself at upload time, store it beside the image, and render it conditionally:

placeholder={blurDataURL ? "blur" : undefined}
blurDataURL={blurDataURL ?? undefined}
Enter fullscreen mode Exit fullscreen mode

Then add a real sizes string, preload only your LCP image, and test the page on a throttled connection.

That is the difference between a page that feels broken and one that feels deliberate.

Top comments (0)