Don't Make This Mistake with Images in Next.js 15

Avoid the Performance-Killing Mistake That's Costing You Lighthouse Points

·Matija Žiberna·
Don't Make This Mistake with Images in Next.js 15

If you're dealing with images in Next.js 15, it's easy to shoot yourself in the foot by misunderstanding how images are handled under the hood. Here's the distinction that matters: are you importing the image or referencing it via URL?

I've seen people (myself included) load local images via dynamic URLs thinking Next.js will optimize them. Spoiler: it won't. And that leads to bad performance, no blur effect, no srcset, no compression. The whole point of next/image is lost.

Two ways to use images in Next.js

Let's break it down with real examples.

First, make sure your tsconfig.json has the right path mapping for easier imports:

{
  "compilerOptions": {
    "paths": {
      "@/public/*": ["./public/*"],
      "@/*": ["./src/*"]
    }
  }
}

Then import your image:

import Image from 'next/image'
import heroImg from '@/public/hero.jpg'

export default function Hero() {
  return (
    <Image
      src={heroImg}
      alt="Hero image"
      placeholder="blur" // This works automatically with static imports
      priority // Use for above-the-fold images
    />
  )
}

What you get automatically:

  • Blur placeholder handled by Next.js
  • Responsive srcSet generated at build time
  • Compression done during build
  • Width and height inferred from the image file
  • Optimal format selection (WebP/AVIF when supported)

Perfect for images you already have in your codebase. This is the default recommended way.

2. Remote URL (CDN or CMS)

import Image from 'next/image'

export default function Gallery({ images }) {
  return (
    <div className="grid grid-cols-3 gap-4">
      {images.map((img) => (
        <Image
          key={img.id}
          src={img.url} // Remote URL from CMS/CDN
          alt={img.alt}
          width={400}
          height={300}
          blurDataURL={img.blurDataURL} // Optional but recommended
          placeholder="blur"
        />
      ))}
    </div>
  )
}

For remote images, you need to:

  • Specify explicit width and height
  • Configure allowed domains in next.config.js
  • Optionally provide blurDataURL for the blur effect
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'your-cms.com',
        port: '',
        pathname: '/images/**',
      },
    ],
  },
}

module.exports = nextConfig

The trap: referencing local images via URL

Here's what NOT to do:

// ❌ Wrong - treating local image like remote
import Image from 'next/image'

export default function BadExample() {
  return (
    <Image
      src="/gallery/photo.jpg" // Local file referenced by URL
      alt="Photo"
      width={800}
      height={600}
      // This won't get optimized!
    />
  )
}

This looks fine, but if that image is just sitting in /public and was never imported, you're serving a raw image directly.

What you lose:

  • No automatic blur placeholder
  • No build-time compression
  • No responsive srcSet generation
  • No format optimization
  • Larger bundle size and slower loading

Your Lighthouse score drops. Mobile users suffer.

When you need blur effects with local URLs

Sometimes you need to reference local images by URL (maybe for dynamic galleries or CMS-like scenarios). In these cases, you can still get blur effects by generating blurDataURL yourself:

import Image from 'next/image'

const photos = [
  {
    src: "/gallery/photo1.jpg",
    alt: "Beautiful landscape",
    base64: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAK..."
  },
  {
    src: "/gallery/photo2.jpg", 
    alt: "City skyline",
    base64: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAK..."
  }
]

export default function Gallery() {
  return (
    <div className="grid grid-cols-3 gap-4">
      {photos.map((photo, index) => (
        <Image
          key={index}
          src={photo.src}
          alt={photo.alt}
          width={500}
          height={375}
          className="w-full h-full object-cover rounded-lg transition-transform duration-300 hover:scale-[1.02]"
          placeholder="blur"
          blurDataURL={photo.base64}
        />
      ))}
    </div>
  )
}

To generate those base64 blur placeholders, use this Sharp script:

const fs = require("fs");
const path = require("path");
const sharp = require("sharp");

async function compressImages(inputDir) {
  try {
    // Ensure the input directory exists
    if (!fs.existsSync(inputDir)) {
      console.error(`Directory ${inputDir} does not exist.`);
      return;
    }

    // Read all files in the directory
    const files = fs.readdirSync(inputDir);

    // Process each image file
    for (const file of files) {
      const inputPath = path.join(inputDir, file);
      const fileExt = path.extname(file);
      const fileName = path.basename(file, fileExt);

      // Skip non-image files
      if (
        ![".jpg", ".jpeg", ".png", ".webp", ".gif"].includes(
          fileExt.toLowerCase()
        )
      ) {
        continue;
      }

      // Compressed image path
      const compressedPath = path.join(inputDir, `${fileName}_tiny${fileExt}`);
      const blurPlaceholderPath = path.join(
        inputDir,
        `${fileName}_tiny.base64`
      );

      // Compress image
      await sharp(inputPath)
        .resize({ width: 800, withoutEnlargement: true }) // Resize to max width of 800px
        .webp({ quality: 80 }) // Convert to WebP with 80% quality
        .toFile(compressedPath);

      // Generate blur placeholder
      const blurBuffer = await sharp(inputPath)
        .resize(10) // Resize to a very small size (e.g., 10px)
        .toFormat("png")
        .toBuffer();

      const blurBase64 = `data:image/png;base64,${blurBuffer.toString(
        "base64"
      )}`;

      // Write blur placeholder to file
      fs.writeFileSync(blurPlaceholderPath, blurBase64);

      console.log(
        `Processed: ${file} -> ${path.basename(
          compressedPath
        )} and ${path.basename(blurPlaceholderPath)}`
      );
    }
  } catch (error) {
    console.error("Error processing images:", error);
  }
}

// Run the script with the gallery directory
compressImages(path.join(__dirname, "..", "public", "gallery"));

Run it with:

npm run compress-images

This generates both optimized images and base64 blur placeholders that you can use in your image data.

Rule of thumb

If the image is local and known ahead of time — import it. If the image is remote — use the URL directly. Never treat a local image like a remote one.

When does manual compression make sense?

The script you shared uses Sharp for manual compression:

const sharp = require("sharp");

async function compressImages(inputDir) {
  // ... compression logic
}

This only makes sense for specific edge cases:

  • Preparing assets for external CDN deployment
  • Processing user uploads server-side
  • Custom preprocessing before shipping files elsewhere
  • Creating specific sizes for art direction

But for general use with next/image, you don't need it. Next.js handles optimization better than manual preprocessing.

Quick checklist

Do this:

  • Import local images: import img from '@/public/img.jpg'
  • Configure remote patterns for external images
  • Use priority for above-the-fold images
  • Let Next.js handle optimization automatically

Don't do this:

  • Reference local images by URL path
  • Manually compress images that Next.js could optimize
  • Skip the alt attribute
  • Forget to configure remotePatterns for external images

The next/image component is powerful when used correctly. Import local images, configure remote sources properly, and let Next.js handle the heavy lifting. Your users (and Lighthouse scores) will thank you.

0

Comments

Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.
Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in