Files
immich-folio/plans/2026-02-14-performance-images-v1.md
ralksta b7217c4a76 feat: photography features & image optimizations
- Hero carousel: gallery.yaml hero accepts single UUID or array, crossfade component
- Full-bleed single albums: subpages with 1 album skip grid, render photos directly
- EXIF on hover: camera/lens/focal length overlay on grid items (toggle via exifOnHover)
- Customizable grid: columns/gap/aspectRatio/layout (masonry|uniform) via gallery.yaml
- Per-subpage grid overrides: each subpage can override global grid config
- True masonry: per-image aspect ratios from EXIF dimensions
- next/image migration: homepage hero, album covers, grid thumbnails, about portrait
- ThumbHash blur placeholders and dominant color backgrounds
- Custom next/image loader via image proxy with width/quality params
- Lightbox fade-in transition with dominant color background
- Password-protected subpages, health check, rate limiting, OG images
2026-02-14 10:30:27 +01:00

9.3 KiB

Performance & Images

Objective

Replace all raw <img> tags with next/image, add ThumbHash blur-up placeholders from Immich metadata, and extract dominant-color placeholders — giving every image a near-instant perceived load with automatic WebP/AVIF optimization and responsive srcsets.

User Review Required

Important

next/image with custom proxy — because images are served through our own /api/image/:token proxy, next/image cannot optimize them server-side by default. Two approaches:

  1. Custom loader — tell next/image to build URLs that point back to our own proxy, passing width and quality as query params. The proxy then asks Immich for the appropriate size. This is the recommended approach and what this plan proposes.
  2. unoptimized prop — skip Next.js image optimization entirely and only get the blur-up / lazy-load benefits. Simpler but loses WebP/AVIF transcoding and responsive srcsets.

This plan uses approach 1 (custom loader) for maximum benefit.

Warning

Lightbox images stay as raw <img> — the Lightbox component is a client-side fullscreen overlay that loads the preview-quality image into a <img>. Converting this to next/image would add unnecessary overhead (srcset negotiation, layout shifts) for an image that's already viewport-sized. The plan leaves Lightbox images as raw <img> but still adds ThumbHash blur placeholders to them.

Implementation Plan

  • 1. Extend the image proxy to accept width/quality params — Modify route.ts to accept optional w (width) and q (quality) query parameters. When w is provided, map it to the closest Immich size tier (thumbnail ≤ 250, preview ≤ 1440, original otherwise). Keep backward compatibility so existing ?size= calls continue to work unchanged.

  • 2. Create a custom next/image loader — Add immichLoader.ts exporting a loader function that generates /api/image/:token?w=WIDTH&q=QUALITY URLs. Must be a standalone file referenced via loaderFile in next.config.ts (required for App Router). Handles the src → token → proxy URL mapping.

  • 3. Configure Next.js for the custom loader — Modify next.config.ts to add images.loader: 'custom' and images.loaderFile: './lib/immichLoader.ts' so next/image uses our proxy globally.

  • 4. Add thumbhash dependency — Modify package.json to add the thumbhash npm package (~2 KB, pure JS, no native deps). Run npm install.

  • 5. Create ThumbHash helper utilities — Add thumbhash.ts with two helpers: thumbHashToDataUrl(base64) converting Immich's base64-encoded ThumbHash into a tiny data URL for next/image's blurDataURL prop, and thumbHashToDominantColor(base64) extracting the average/dominant color as a hex string from the ThumbHash's decoded RGBA data. Since ThumbHash encodes both shape and color, both blur placeholder and dominant color come from the same Immich field — no extra API calls.

  • 6. Add placeholder helper to URL utilities — Modify urls.ts to add an assetPlaceholder(asset) helper returning { blurDataURL, dominantColor } | null. This encapsulates ThumbHash → placeholder conversion server-side so page components just pass it through as props.

  • 7. Migrate homepage hero to next/image — Modify page.tsx to replace the raw <img> (line 58) with next/image using fill layout and priority (above-the-fold LCP image). Add blurDataURL and placeholder="blur" from the hero asset's ThumbHash. Fetch ThumbHash via immich.getAssetInfo() for the hero image ID.

  • 8. Migrate subpage album covers to next/image — Modify page.tsx to replace album cover <img> tags (line 98) with next/image using fill layout. Pass blurDataURL and placeholder="blur" from each album's thumbnail asset ThumbHash. Batch-fetch ThumbHash data for albumThumbnailAssetId when loading subpage data.

  • 9. Migrate about page portrait to next/image — Modify about/page.tsx to replace the portrait <img> (line 33) with next/image using fill layout and ThumbHash blur placeholder.

  • 10. Migrate photo grid thumbnails to next/image — Modify PhotoGrid.tsx to replace grid <img> tags (line 84) with next/image using fill layout and sizes attribute for responsive sizing. Extend the PhotoItem interface with optional blurDataURL and dominantColor fields, generated server-side and passed as props. Use placeholder="blur" when available. Use dominantColor as CSS background-color on the grid item container for an instant color flash.

  • 11. Add placeholders to the Lightbox — Modify Lightbox.tsx to keep using raw <img> for fullscreen preview but add dominant-color background on .lightbox__image-container and show the ThumbHash blur as a CSS background-image while the main image loads, fading it out with a CSS transition on the image's onload event.

  • 12. Add CSS for placeholder transitions — Modify globals.css to add smooth blur-to-sharp opacity transitions on grid items (pseudo-element or background-image), dominant color background styling for grid items, lightbox container, and album covers.

  • 13. Verify build and lint — Run npm run build and npm run lint to confirm no TypeScript or ESLint errors.

  • 14. Manual visual verification — Run npm run dev, test homepage hero blur, subpage album cover placeholders, photo grid blur-up, lightbox dominant color background, and about page portrait placeholder. Throttle to Slow 3G in DevTools to validate placeholder visibility.

Verification Criteria

Automated Tests

  • npm run build — verify the project builds successfully with all changes (no TypeScript / ESLint errors)
  • npm run lint — verify ESLint passes

Manual Verification

  1. Run npm run dev and open http://localhost:3000 in the browser
  2. Homepage hero: Verify the hero image loads with a visible blur placeholder that transitions to the sharp image. Open DevTools Network tab and confirm the image request goes through /api/image/ and the response Content-Type is image/webp or image/avif
  3. Subpage grid: Navigate to a subpage, verify album cover cards show a colored/blurred placeholder before the cover image loads
  4. Photo grid: Open an album, verify grid thumbnails show blur-up placeholders. Inspect a thumbnail <img> in DevTools and confirm it has srcset with multiple widths
  5. Lightbox: Click a photo, verify the lightbox shows a dominant color background before the full preview loads
  6. About page: Navigate to /about, verify the portrait image uses blur-up placeholder
  7. Slow connection test: In DevTools, throttle network to "Slow 3G" and reload an album page — confirm placeholder behavior is clearly visible (blur → sharp transition)

Potential Risks and Mitigations

  1. next/image with custom loader may not support all optimization features Mitigation: The custom loader approach is officially documented by Next.js. Worst case, we fall back to unoptimized mode and still get blur placeholders.

  2. ThumbHash decoding adds server-side compute per page render Mitigation: ThumbHash decoding is extremely fast (microseconds per hash). The data is already cached in the album response. We decode once per render, and albums are cached via the existing TTL cache.

  3. Missing ThumbHash data for some assets Mitigation: All helpers return null when ThumbHash is unavailable. Components fall back gracefully — no placeholder shown, image loads normally (current behavior).

  4. Lightbox image loading UX regression Mitigation: We're adding placeholders to the lightbox (improvement), not changing the image loading mechanism. The raw <img> stays, so no layout shift or srcset complexity.

Alternative Approaches

  1. BlurHash instead of ThumbHash: Immich uses ThumbHash (not BlurHash) internally. ThumbHash is the newer format and encodes both blur shape + color info, making it strictly better for our use case. No reason to use the older BlurHash format.

  2. Server-side image optimization (Sharp): Instead of the custom loader approach, we could add Sharp to the proxy route and actually resize/transcode images server-side. This would give true optimization but adds significant CPU and memory load to the server. Not recommended for a self-hosted gallery.

  3. plaiceholder package: A popular Next.js package for generating blur placeholders. However, it requires fetching the actual image server-side to compute the placeholder. Since Immich already provides ThumbHash data, using it directly is much more efficient.