Skip to content
LOCZH/安大略 · 加拿大待机OK/--:--:--EST
M4M4RK_YUportfolio
  • 项目
    项目Overview
    • 作品精选案例与项目记录
    • 游戏可玩原型与游戏开发日志
  • 影像
    影像Overview
    • 档案影像合集与视觉实验
    • 商店印刷品、海报和限量物件
  • 日志
    日志Overview
    • 博客长篇开发日志与现场笔记
    • 笔记短观察、链接与代码片段
  • 资源
    资源Overview
    • 工具38 款浏览器内开发工具
    • 链接每日使用的开发与设计书签
  • 关于
  • 联系
EN

同步 · dev.to / @markyu

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...

发布日期
Jun 4
·
阅读时长
10 min read
nextjsperformancewebdevreact
在 dev.to 查看

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" }}
    />
  );
}

The important part is this pair:

width={1600}
height={900}

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

Then this part makes it responsive:

style={{ width: "100%", height: "auto" }}

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>
  );
}

For fill, the parent needs:

position: "relative"

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

aspectRatio: "16 / 9"

So your two stable layout patterns are:

<Image width={1600} height={900} />

or:

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

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 sourceAutomatic blurDataURL?What you do
Static importYesAdd placeholder="blur"
Remote URLNoGenerate and pass blurDataURL yourself
Dynamic CMS/Supabase/S3 URLNoStore 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" }}
    />
  );
}

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);
}

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`);

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;

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
  );

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;

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;
}

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

{
  width: number;
  height: number;
  blur_data_url: string | null;
}

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>
  );
}

This conditional is important:

placeholder={blurDataURL ? "blur" : undefined}
blurDataURL={blurDataURL ?? undefined}

Do not do this blindly:

placeholder="blur"

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"

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"

For a three-column gallery, this is better:

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

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 />

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" }}
    />
  );
}

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} />

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;

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>
  );
}

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}

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>

This is stable:

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

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} />

or:

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

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}

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.

相关阅读

react

Premium micro-interactions in React 19 (without the jank)

There's a specific kind of bad animation I notice immediately: the count-up stat that stutters as it...

webdev

Implementing 3D Graphics in React

3D is an exciting area in computer science, and it could range from creating 3D shapes, vectors,...

react

3 Ways To Create Engaging React Loading Screens with Hooks

Creating engaging loading screens for your React applications can greatly enhance the user...

原文发布

本文首发于 dev.to,评论与点赞保留在原站。

在 dev.to 继续阅读
上一篇The True Cost of Poor Data Quality: Why It Matters and How to Improve ItIn today’s fast-paced, data-driven world, businesses have more access to data than ever before....
返回档案
下一篇11 Free 3D Asset Sites for Games, Blender, and WebGLFinding good 3D assets is one of those tasks that sounds easy until you actually need them. You...
返回档案
频道开放·随时打个招呼 · 2026
--:--:--EST
联系

看到什么有意思的?和我聊聊。

这是一个作品集,不是服务 · 但每一条留言我都会看 — 如果哪里让你有所触动,或者只想打个招呼,欢迎写信过来。

开启对话

订阅

偶尔收到一封简讯

来自 m4rkyu.com 的笔记与日志——简短、标注日期、没有杂音。随时可退订。

作品

线上发布、游戏作品与视觉档案。

  • 项目
  • 游戏
  • 档案
  • 日志

资源

每日好用的工具与个人收藏的链接库。

  • 搜索
  • 最新
  • 工具
  • 链接
  • 笔记
  • 主题
  • RSS
  • JSON Feed
  • 商店

工作室

背景、联系方式以及合作渠道。

  • 关于
  • 联系
  • 更新日志
  • 技术说明
  • 简历筹备中

社交

在常去的平台上找到我。

  • Facebook敬请期待
  • Instagram敬请期待
  • YouTube敬请期待
  • 领英敬请期待
M4RKYUM4RKYUM4RKYUM4RKYUM4RKYUM4RKYUM4RKYUM4RKYU
始于 2024
ZhenXiao Mark YuZhenXiao Mark Yu
© 2026 ZhenXiao Mark Yu·加拿大 安大略
  • 邮件
  • GitHub
  • dev.to
  • 领英 (敬请期待)
  • 推特 / X (敬请期待)
  • Instagram (敬请期待)
由 Next.js 16 · React 19 · Tailwind 4 构建

由 Next.js 16 · React 19 · Tailwind 4 构建