Don't Make This Mistake with Images in Next.js 15
Avoid the Performance-Killing Mistake That's Costing You Lighthouse Points

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.
1. Static import (recommended for local images)
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
andheight
- 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.