Preserving P3 Color Space in Next.js Image Optimization

Matt Legrand
June 20, 2023·8 min read
Boquet of flowers in high dynamic range

Next.js Image optimization destroys HDR colors

Next.js Image handles resizing, lazy loading, format conversion, and caching automatically. It also destroys wide color gamut images by converting everything to sRGB. If you work with color-critical content, you get fast images that look washed out.

I built a workaround that preserves P3 color space while keeping most of Next.js Image’s optimization benefits, and proposed a native fix upstream.

When optimization destroys beauty

Under the hood, Next.js Image uses Sharp.js for server-side processing. Sharp strips ICC color profiles by default and converts to sRGB. Good for compatibility. Bad for anything designed in Display P3.

The pipeline takes your P3 PNGs, runs them through Sharp, and outputs sRGB WebP. Great compression, washed out colors.

Display P3 vs sRGB color comparison (sRGB)
Display P3 vs sRGB color comparison (Display-P3)
Display-P3sRGB
tsx
// This PNG automatically becomes WebP for supporting browsers
<Image src="/hdr-image.png" width={800} height={600} alt="Auto-converted to WebP" />

Images designed in Display P3 contain colors that don’t exist in sRGB. Sharp clips them to the nearest sRGB equivalent, and you end up with noticeably duller results on P3-capable displays.

The fix is two lines of Sharp config:

javascript
// Default Next.js processing
sharp(inputBuffer)
  .resize(width, height)
  .webp({ quality: 75 }) // No ICC profile preservation
  .toBuffer();

// With P3 preservation
sharp(inputBuffer)
  .resize(width, height)
  .keepIccProfile()
  .webp({
    quality: 75,
    smartSubsample: false, // Maintains color accuracy
  })
  .toBuffer();

Today, the only escape hatch is the unoptimized prop, which bypasses Sharp entirely:

tsx
<Image unoptimized src="/hdr-photo.webp" width={800} height={600} alt="Full color, no optimization" />

This preserves your colors but kills every optimization benefit: no resizing, no format conversion, no responsive srcset. Fine for a hero image or two, but it doesn’t scale.

Understanding P3 Color Space

While sRGB has served as the web standard since the 1990s, it covers only a fraction of colors the human eye can perceive.

Display P3 is a superset of sRGB, meaning any sRGB color can be represented in P3, but P3 can represent approximately 25% wider gamutDean Jackson, “Improving Color on the Web,” WebKit Blog, June 2016 than sRGB, particularly in the deep reds and vibrant greens that make interface designs pop. A color like #ff0000 in sRGB becomes a much more saturated red when interpreted as P3, while colors that exist in P3 space simply cannot be represented in sRGB at all.

The technical mechanism is straightforward: P3 uses wider primary color coordinates than sRGB. When a color exists in P3 but not in sRGB, conversion clips it to the nearest representable value.

The practical impact is substantial. Modern displays like MacBooks, iMacs, high-end monitors, and most mobile devices can reproduce P3 colors. Safari has supported P3 in CSS since 2016, and Chrome finally achieved full support in 2023Chrome 111 Beta: CSS Color Level 4,” Chrome for Developers, February 2023. We’re at an inflection point where wide color gamut is becoming the norm, not the exception.

For UI design, this expanded palette is particularly valuable. Interface elements that rely on color intensity for hierarchy, vibrant accent colors that guide user attention, and the subtle gradients that give modern designs their depth all benefit dramatically from P3’s expanded range.

A color-preserving P3Image component

Rather than fighting Next.js Image’s architecture, I built a P3Image component that wraps it. Local images get routed through a custom API that processes them with Sharp’s ICC preservation. External URLs pass through unchanged.

tsx
import P3Image from '@/components/P3Image';

<P3Image
  src="/hdr-image.webp"
  width={800}
  height={600}
  alt="Vibrant UI design"
  preserveP3={true} // default
/>

<P3Image
  src="/simple-icon.png"
  width={100}
  height={100}
  alt="Simple icon"
  preserveP3={false}
/>

The component is a client-side URL generator. It builds requests that trigger server-side Sharp processing while keeping all of Next.js Image’s client behavior: lazy loading, responsive srcset, event handlers.

tsx
export default function P3Image({ src, width, height, ... }) {
  const p3Loader = ({ src, width, quality }) => {
    return `/api/image?src=${src}&w=${width}&q=${quality}`;
  };

  return <NextImage loader={p3Loader} src={src} ... />;
}

The loader routes local images through our API and passes external URLs through unchanged:

tsx
const p3Loader = ({ src, width, quality }) => {
  if (src.startsWith("http://") || src.startsWith("https://")) {
    return `${src}?w=${width}&q=${quality}`;
  }

  const params = new URLSearchParams({
    src: src,
    w: width.toString(),
    q: quality.toString(),
    format: format,
  });

  return `/api/image?${params.toString()}`;
};

On the server side, the processor checks for existing ICC profiles and either preserves them with keepIccProfile() or adds a P3 profile with withIccProfile('p3') for images that lack color information. See Sharp’s ICC profile API for details.

javascript
const metadata = await sharpInstance.metadata();

if (metadata.icc) {
  sharpInstance = sharpInstance.keepIccProfile();
} else {
  sharpInstance = sharpInstance.withIccProfile("p3");
}

const optimizedBuffer = await sharpInstance
  .webp({
    quality: quality,
    effort: 4,
    smartSubsample: false,
  })
  .toBuffer();

The smartSubsample: false setting prevents chroma subsampling that degrades color accuracy. For JPEG output, chromaSubsampling: '4:4:4' replaces the default '4:2:0' to maintain full color resolution. These settings add 10-20% processing time per image, but the results are cached.

My implementation also includes intelligent caching and security measures:

javascript
// Generate cache filename based on source and parameters
const hash = crypto.createHash("md5").update(`${src}_w${width}_q${quality}_${format}`).digest("hex");
const cacheFilename = `${hash}_p3.${format}`;

// Security: Prevent path traversal
if (src.includes("..") || src.includes("\\") || !src.startsWith("/")) {
  return NextResponse.json({ error: "Invalid src parameter" }, { status: 400 });
}

The caching strategy compares modification times between source and cached files, invalidating when sources change. Path traversal is blocked at the API route level.

Performance tradeoffs

The main cost is losing Vercel’s edge network for image processing. Our API runs on the server or serverless function, so first loads add 50-250ms depending on geographic distance. Cached loads are identical to native Next.js.

Everything else carries over: lazy loading, responsive srcset, preload={true} for above-fold images, onLoad and onError handlers. We also get P3-aware WebP, JPEG, and PNG output, plus a custom X-P3-Preserved header for debugging.

What we lose: edge distribution (solvable with a CDN), automatic AVIF support (could be added), and auto format detection (we explicitly set output format). For most sites, cached requests are the majority of traffic, so the practical impact is small.

unoptimized vs. preserveColorProfile

The file size difference tells the story. Using unoptimized bypasses Sharp entirely, serving the original file at every viewport width. With preserveColorProfile, Sharp still resizes, compresses, and generates responsive srcset variants while keeping the ICC profile intact.

ViewportunoptimizedDefault <Image>preserveColorProfile
640px3,340 KB12 KB12 KB
828px3,340 KB17 KB17 KB
1080px3,340 KB25 KB24 KB
1200px3,340 KB28 KB28 KB
1920px3,340 KB54 KB51 KB
2250px3,340 KB69 KB66 KB

Measured from a 2250×1500 lossless P3 WebP source, processed with Sharp at quality 75. Default <Image> strips the ICC profile and converts to sRGB. preserveColorProfile retains full Display P3 fidelity at nearly identical file sizes.

You can also apply P3 selectively:

tsx
function SmartImage({ src, preserveP3 = false, ...props }) {
  if (preserveP3 && isColorCritical(src)) {
    return <P3Image src={src} {...props} />;
  }
  return <Image src={src} {...props} />;
}

Testing with P3-gamut design interfaces showed the difference clearly. On P3-capable displays, the blues, purples, and greens in UI mockups were visibly more saturated and true to the original design files. First loads ran 100-300ms versus 50-200ms for standard Next.js, with cached loads identical at 10-50ms and only a 2KB bundle size increase.

When to use P3Image

Use P3Image for color-critical content: UI design screenshots, photography, product images for premium brands, portfolio pieces, brand materials. Use standard Next.js Image for everything else: icons, diagrams, thumbnails where loading speed matters more than color accuracy, monochrome or low-saturation content.

tsx
<P3Image src="/ui-design-screenshot.jpg" preserveP3={true} />
<P3Image src="/product-photo.jpg" preserveP3={true} />

<Image src="/icon.svg" />
<Image src="/simple-diagram.png" />

A case for native support

The P3Image component works, but it shouldn’t be necessary. The fix is two lines in Sharp’s pipeline. There’s no reason this can’t live in Next.js itself.

I’ve opened a draft PR on the Next.js repository proposing a preserveColorProfile option at both the global config and per-image level:

ts
// next.config.ts
const nextConfig = {
  images: {
    preserveColorProfile: true,
  },
};
tsx
// Per-image override
<Image src="/vibrant-photo.jpg" width={800} height={600} preserveColorProfile />

// Standard sRGB optimization (default)
<Image src="/simple-icon.png" width={100} height={100} />

When enabled, the optimizer calls keepIccProfile(), disables smartSubsample for WebP, and uses chromaSubsampling: '4:4:4' for JPEG. It defaults to false, so existing behavior is untouched.

This would eliminate the need for custom components, API routes, and caching layers. The global config handles sites where everything should preserve color. The per-image prop handles selective cases. Either way, no extra infrastructure.

P3-capable displays are the norm now. Silently converting everything to sRGB is an increasingly bad default.