Skip to main content

Deliver images

How Nuxt Photo sizes every thumbnail, slide and photo - srcset from your Nuxt Image screens, sizes from the layout, priority and preload for the first screen, build-time blur placeholders, browser cache headers, and the Nuxt Image config for Vercel, static sites, IPX and Cloudinary.

Nuxt Photo asks your image service for the widths a photo can be shown at, and tells the browser how wide each photo is on screen. The browser then downloads one file close to the size it needs. With @nuxt/image installed this works without Nuxt Photo options: quality, format, widths and the provider all come from your image config.

How a size is chosen

Every image gets two attributes:

AttributeWho writes itWhat it says
srcsetNuxt Photo, from the widths your image service allowsThe files that exist: …?w=256 256w, …?w=640 640w, …
sizesThe component that knows the layoutHow wide this image is on screen: auto, 212px

The browser multiplies the sizes width by the screen's pixel density and picks the smallest file that is large enough. Two rules keep this correct:

  • No file is wider than the source. A 1000 px photo never gets a 1280w entry. The source width is the last entry when it falls between two widths.
  • The layout owns sizes. PhotoAlbum writes the exact width of each thumbnail from its layout, already in the server-rendered HTML. PhotoCarousel uses slideSize and the measured track. The lightbox uses the size of its frame. <Photo> uses auto with a fallback. A sizes prop on PhotoAlbum or PhotoImage replaces the computed value.

sizes="auto, …" lets browsers that support it use the measured width of a lazy image. Other browsers use the pixel value after it.

Use Nuxt Image

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/image', '@lupinum/nuxt-photo'],
  image: {
    quality: 80,
  },
})

Nuxt Photo detects @nuxt/image and uses its default provider. Module order does not matter. These Nuxt Image options apply to every Nuxt Photo image:

Nuxt Image optionEffect in Nuxt Photo
providerBuilds every URL. Choose another one for Nuxt Photo only with nuxtPhoto.provider: 'cloudinary', or per component with provider="cloudinary".
screens × densitiesThe widths in srcset. With the defaults this is 640 to 3072 px. For IPX and other providers that accept any width, Nuxt Photo adds 128, 256, 384 and 512 px below that, for thumbnails.
qualitySent with every image.
formatThe first format is sent to IPX. Without a format, IPX gets webp. Vercel, Netlify and Cloudinary choose AVIF or WebP from the browser's Accept header instead.
domainsHosts of remote images, without https://. A remote image whose host is missing fails to load.

Animated GIFs never get a format, so they keep their animation. SVG files are passed through unchanged, with no srcset.

Deploy on Vercel

On Vercel, Nuxt Image uses the vercel provider by itself. Vercel only serves widths from a list that Nuxt Image builds from image.screens alone, not from screens × densities. Nuxt Photo uses exactly that list, so no width is rejected. The default screens run from 640 to 1536 px, so add small widths for thumbnails and large widths for 2× screens:

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/image', '@lupinum/nuxt-photo'],
  image: {
    quality: 80,
    screens: {
      '3xs': 128,
      '2xs': 256,
      xs: 384,
      '3xl': 1920,
      '4xl': 2560,
      '5xl': 3072,
    },
  },
})

The default screens (sm to 2xl) stay, because Nuxt Image merges this object with its defaults. Without the small screens, a 64 px thumbnail downloads a 640 px file. Without the large ones, a lightbox on a 2× laptop gets at most 1536 px.

Vercel keeps each transformed image on its CDN. For files in public/ this lasts up to 31 days, and a redeploy does not reset it. For remote images it lasts for the longer of the source's Cache-Control: max-age and minimumCacheTTL, which Nuxt Image sets to 300 seconds. When your CMS gives an edited image a new URL, raise it so visitors do not wait for a new transform every five minutes:

nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    vercel: { config: { images: { minimumCacheTTL: 2678400 } } },
  },
})

Use the vercel provider for server-rendered sites on Vercel. IPX inside a Vercel function cannot read public/, so local images return 404.

Let browsers keep images

Without a cache header, browsers ask the server again for every image on every visit. That costs a round trip per image while scrolling, even when the CDN answers from its cache. Two defaults cause it:

WhereDefaultEffect
Vercel, files in public/max-age=0, must-revalidateThe optimized image inherits it.
IPXimage.ipx.maxAge: 60Browsers and CDNs keep a transformed image for one minute.

Give image folders a long cache time:

nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/photos/**': {
      headers: { 'cache-control': 'public, max-age=2592000, stale-while-revalidate=604800' },
    },
  },
  image: {
    ipx: { maxAge: 2592000 },
  },
})

Vercel passes the source file's max-age on to the optimized image. Use 30 days: Vercel keeps an optimized image up to 31 days, and a shorter max-age makes browsers ask again once the cached copy is older than that. When you replace an image, give it a new file name, so browsers do not keep the old one.

Generate a static site

With nuxi generate, Nuxt Image uses ipxStatic: every width in every srcset is written to .output/public at build time, and no image is transformed when a visitor scrolls. The build takes longer with many photos. Thumbnails and slides that the server renders are included. A gallery that renders only in the browser (inside <ClientOnly>) is not; prerender its route or use a provider that transforms at request time.

When IPX transforms at request time on your own server, keep format at webp. AVIF takes much longer to encode, and the first visitor waits for it.

Use Cloudinary or another provider

Any Nuxt Image provider works. Set it as the default provider, or only for Nuxt Photo:

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/image', '@lupinum/nuxt-photo'],
  image: {
    cloudinary: {
      baseURL: 'https://res.cloudinary.com/<your-cloud-name>/image/upload/',
    },
  },
  nuxtPhoto: {
    provider: 'cloudinary',
  },
})

Set each photo's src to the asset path after baseURL, with its original size:

ts
const photo: PhotoItem = {
  id: asset.public_id,
  src: `/${asset.public_id}.${asset.format}`,
  width: asset.width,
  height: asset.height,
  alt: asset.context?.custom?.alt,
}

For an image service without a Nuxt Image provider, or in plain Vue, write a PhotoProvider: see integrate a custom image service.

Show a placeholder while an image loads

Nuxt Photo never shows an empty box. Each photo frame shows, in this order:

  1. placeholderSrc from the photo, when you set it.
  2. For files in public/: a blurred preview at most 32 px wide, made at build time and inlined as a data URL. Nuxt Photo makes it with sharp, which @nuxt/image already installs for IPX. Without sharp there is no preview, and the build logs one warning. Photos from usePhotoFolder and, with localImages, hand-written arrays get it.
  3. For remote images on IPX: a 24 px image from the provider.
  4. Otherwise the background color --np-placeholder-bg.

When an image fails to load, the placeholder stays, so the layout does not show an empty frame.

Load the first screen first

Images are lazy by default. Mark the images a visitor sees first, so the browser loads them before anything else:

app/pages/index.vue
<template>
  <Photo :photo="cover" priority />
  <PhotoAlbum :photos="photos" :priority="6" />
</template>

priority sets loading="eager" and fetchpriority="high". In Nuxt it also adds a <link rel="preload"> for the image to the page head, so the browser starts the download before it reads the rest of the page. At most six images get a preload. On PhotoAlbum, priority is the number of thumbnails, counted from the start. Set it to the number of thumbnails in the first screen; a higher number delays the rest of the page. The first slide of PhotoCarousel always has high priority.

A <Photo> cannot know its width before the page is shown, so it uses 100vw. When the photo is narrower, say so, or the browser downloads a file for the full screen width:

vue
<Photo :photo="cover" priority sizes="(min-width: 1024px) 720px, 100vw" />

Images below the first screen start loading about one and a half screen heights before they scroll into view, in every browser. The lightbox requests a slide only when it opens. After the open photo has loaded, it loads the previous and the next photo in the background, so the next swipe shows a ready image. With the browser's data saver on, it skips this.

Check the result

Open the network panel, disable the cache, and reload:

  • Thumbnails request files close to their displayed width times the screen's pixel density, never wider than the source.
  • With Nuxt Image, image URLs go through your provider: /_ipx/… for IPX, /_vercel/image?… on Vercel. On Vercel, a second load shows x-vercel-cache: HIT.
  • Images below the first screen are requested as you scroll toward them, and a placeholder is visible until each one is ready.
  • If local images work and remote ones fail, add the host to image.domains.