Image Optimization with next/image

Step 29 of 31Next.js Tutorial Series | Source code for this step

Live Demo →


What You Will Build

↑ Index

Our blog posts show the author name, but there's no visual avatar. In this step, you will:

  1. Add author avatars using placeholder images from an external service
  2. Use the <Image> component from next/image for automatic optimization
  3. Configure next.config.ts to allow external image domains
  4. Understand how Next.js optimizes images — resizing, format conversion, lazy loading

Goal: Learn the next/image component — the standard way to handle images in Next.js applications.


Table of Contents

  1. Why next/image Instead of img
  2. The Image Component API
  3. Configuring External Image Domains
  4. Adding Avatars to the Post Detail Page
  5. Adding Avatars to the Posts List
  6. How Image Optimization Works
  7. Local vs External Images
  8. Verify the Images
  9. Summary & Key Takeaways

Why next/image Instead of img

↑ Index

The standard HTML <img> tag works but has problems:

Problem<img><Image> from next/image
SizeServes the original file (could be 5MB)Automatically resizes to the displayed size
FormatServes the original format (PNG, JPEG)Converts to WebP/AVIF (smaller, modern formats)
LoadingLoads all images immediatelyLazy-loads images (only loads when near the viewport)
Layout shiftImage "jumps" when it loadsReserves space — no layout shift (you specify width/height)
ResponsiveManual work with srcsetAutomatic srcset generation

The <Image> component solves all of these automatically. It's one of the biggest performance wins Next.js offers with minimal effort.


The Image Component API

↑ Index

import Image from "next/image";

<Image
  src="/avatar.jpg"       // Image source — local path or URL
  alt="User avatar"       // Alt text — required for accessibility
  width={40}              // Display width in pixels
  height={40}             // Display height in pixels
  className="rounded-full" // Standard CSS classes work
/>

Required props

PropPurpose
srcImage source — a local path (e.g., /avatar.jpg) or external URL
altAccessibility text — describes the image for screen readers
widthDisplay width in pixels (required for external images)
heightDisplay height in pixels (required for external images)

Common optional props

PropPurposeDefault
priorityLoad immediately (skip lazy loading)false
placeholderShow a placeholder while loading"empty"
qualityImage quality (1-100)75
sizesResponsive sizes hintauto

priority should be set to true for images that are above the fold — visible without scrolling. For example, the hero image or the first avatar the user sees. This tells the browser to download it immediately instead of waiting.


Configuring External Image Domains

↑ Index

By default, next/image only optimizes local images (files in your public/ directory). To use external images, you must configure the allowed domains in next.config.ts.

We will use UI Avatars (ui-avatars.com) — a free service that generates letter-based avatars from a name. For example:

https://ui-avatars.com/api/?name=Alice&background=0d9488&color=fff&size=80

This generates a teal circle with the letter "A" — no image uploads needed.

Update next.config.ts:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "ui-avatars.com",
        pathname: "/api/**",
      },
    ],
  },
};

export default nextConfig;

Why remotePatterns instead of domains?

The domains config is deprecated. remotePatterns is more secure because you can specify:

  • protocol — only https (not http)
  • hostname — the exact domain
  • pathname — which paths are allowed (e.g., only /api/**)

This prevents someone from injecting a malicious image URL that happens to be on the same domain.


Adding Avatars to the Post Detail Page

↑ Index

Update app/posts/[id]/page.tsx to show an avatar next to the author name.

Find the author info section and add an avatar:

import Image from "next/image";

Then in the JSX, replace the author line:

<div className="flex items-center gap-3 mb-6">
  <Image
    src={`https://ui-avatars.com/api/?name=${encodeURIComponent(post.author?.name ?? "U")}&background=0d9488&color=fff&size=80`}
    alt={post.author?.name ?? "Unknown"}
    width={40}
    height={40}
    className="rounded-full"
  />
  <div>
    <p className="text-sm font-medium text-gray-800">
      {post.author?.name ?? "Unknown"}
    </p>
    <p className="text-xs text-gray-400">
      {new Date(post.createdAt).toLocaleDateString()}
    </p>
  </div>
</div>

What's happening

  1. encodeURIComponent — ensures the author name is URL-safe (handles spaces and special characters)
  2. background=0d9488 — teal background color (matches our app's theme)
  3. color=fff — white text
  4. size=80 — the source image is 80x80px (2x the display size for retina screens)
  5. width={40} and height={40} — the display size is 40x40px
  6. className="rounded-full" — makes it circular

Why is size=80 but width={40}?

For sharp images on high-DPI (retina) screens, you want the source image to be at least 2x the display size. The <Image> component handles the resizing — it serves a 40px image to regular screens and an 80px image to retina screens.


Adding Avatars to the Posts List

↑ Index

Update app/posts/PostList.tsx to show avatars in the posts listing. Add the import:

import Image from "next/image";

Then update the post card's author section:

<div className="flex items-center gap-2 mb-3">
  <Image
    src={`https://ui-avatars.com/api/?name=${encodeURIComponent(post.author?.name ?? "U")}&background=0d9488&color=fff&size=64`}
    alt={post.author?.name ?? "Unknown"}
    width={32}
    height={32}
    className="rounded-full"
  />
  <p className="text-sm text-gray-500">
    {post.author?.name ?? "Unknown"} &middot;{" "}
    {new Date(post.createdAt).toLocaleDateString()}
  </p>
</div>

Here we use a smaller avatar (32x32 display, 64px source) since the listing cards are more compact than the detail page.


How Image Optimization Works

↑ Index

When a browser requests an image through next/image, Next.js does the following:

Browser requests /posts/1
    │
    ▼
<Image src="https://ui-avatars.com/..." width={40} height={40} />
    │
    ▼
Next.js generates: <img srcset="/_next/image?url=...&w=40 1x, /_next/image?url=...&w=80 2x" />
    │
    ▼
Browser requests /_next/image?url=https://ui-avatars.com/...&w=80&q=75
    │
    ▼
Next.js Image Optimization API:
  1. Fetches the image from ui-avatars.com
  2. Resizes to 80px width
  3. Converts to WebP format
  4. Caches the result
  5. Sends the optimized image to the browser

On subsequent requests

The optimized image is cached. The next browser that requests the same image gets the cached version — no re-fetch, no re-resize.

On Vercel

Vercel's Image Optimization runs at the edge — close to the user. Images are optimized once and cached globally. This is included in Vercel's free tier.

The generated HTML

If you inspect the rendered HTML, you'll see something like:

<img
  alt="Alice"
  loading="lazy"
  width="40"
  height="40"
  decoding="async"
  srcset="/_next/image?url=https%3A%2F%2Fui-avatars.com...&w=48&q=75 1x,
          /_next/image?url=https%3A%2F%2Fui-avatars.com...&w=96&q=75 2x"
  src="/_next/image?url=https%3A%2F%2Fui-avatars.com...&w=96&q=75"
  class="rounded-full"
/>

Notice:

  • loading="lazy" — browser lazy-loads the image
  • decoding="async" — browser decodes the image off the main thread
  • srcset — provides 1x and 2x versions for different screen densities
  • All URLs go through /_next/image — Next.js's optimization proxy

Local vs External Images

↑ Index

Local imagesExternal images
SourceFiles in public/ or importedURLs from external services
Config needed?NoYes — must add to remotePatterns
width/height required?No — auto-detected from the fileYes — must be specified
placeholder="blur"Supported — auto-generates blur hashNot supported (no access to file at build time)

Local image example

import profilePic from "@/images/profile.jpg";

<Image src={profilePic} alt="Profile" />
// width and height are automatically detected from the file

External image example (what we're doing)

<Image
  src="https://ui-avatars.com/api/?name=Alice"
  alt="Alice"
  width={40}
  height={40}
/>
// width and height must be specified

For our blog app, we use external images because avatars are generated dynamically based on the user's name. If you had static images (e.g., a logo), you'd import them locally.


Verify the Images

↑ Index

  1. Start the dev server: pnpm dev
  2. Visit /posts — each post card should show a circular avatar with the author's initial
  3. Click on a post — the detail page should show a larger avatar with the author name and date next to it
  4. Open DevTools → Network tab → filter by "image" — you should see requests to /_next/image (not directly to ui-avatars.com)
  5. Check the image format — it should be webp (not the original PNG from ui-avatars)

Troubleshooting

If images don't appear:

  • Check next.config.ts — make sure ui-avatars.com is in remotePatterns
  • Restart the dev server after changing next.config.ts — config changes require a restart
  • Check the browser console for errors — you might see "Invalid src prop" if the domain isn't configured

Summary & Key Takeaways

↑ Index

ConceptDetails
<Image> vs <img><Image> auto-optimizes: resizes, converts to WebP, lazy-loads, prevents layout shift
next/image importimport Image from "next/image" — the component
width and heightRequired for external images — reserves space to prevent layout shift
prioritySet to true for above-the-fold images — skips lazy loading
remotePatternsConfigure allowed external image domains in next.config.ts
/_next/imageAll images go through Next.js's optimization proxy
Retina supportServe 2x source images — Next.js generates srcset automatically
CachingOptimized images are cached on the server — subsequent requests are instant
Local imagesImport directly — width/height auto-detected, blur placeholder supported

What is Next

In Step 30, we will explore Prisma Query Insights — a dashboard built into Prisma Postgres that helps you identify slow queries, N+1 problems, and missing indexes.