Nuxt and Vite Image Asset Pipeline

Nuxt and Vite approach responsive image delivery from two different altitudes, and understanding the seam between them is the difference between a build that emits perfectly-tuned AVIF srcset sets and one that ships a single oversized JPEG to every viewport. This page is part of Framework & Build-Tool Media Integration and dissects both layers: the high-level @nuxt/image component system with its <NuxtImg> and <NuxtPicture> components and provider abstraction, and the lower-level Vite asset pipeline — import ?url, import.meta.glob, and vite-imagetools — that transforms raw source files into hashed, srcset-ready outputs at build time. The two are not mutually exclusive: Nuxt runs on Vite, so a Nuxt app can use component-level modifiers for content-driven imagery and raw imagetools imports for hand-tuned hero art in the same codebase.

Concept & architecture

Two modes: build-time transform vs provider/CDN

Every image module in this ecosystem resolves to one of two operating modes, and the mode dictates when and where the actual pixel resampling happens.

Build-time (static) mode runs the encoder during nuxt build or vite build. For Nuxt that means the bundled IPX provider (backed by Sharp/libvips) writes derivative files into the output directory, or — when Nuxt runs as a server — IPX resolves them on demand from a filesystem cache. For raw Vite, vite-imagetools performs the transform inside the Rollup graph: it reads the source, calls Sharp, and emits hashed assets plus the srcset string as a module export. The CPU cost is paid once, in CI, and the CDN only ever serves already-optimised bytes.

Provider/CDN mode defers the transform to a remote image service. @nuxt/image ships first-party providers for Cloudinary, imgix, Cloudflare, Netlify, Vercel, and a generic ipx/ipxStatic provider. In this mode <NuxtImg> does not encode anything — it constructs URLs (https://res.cloudinary.com/.../w_800,f_avif/hero.jpg) that the provider expands at request time. The encode cost moves off your build machine and onto the CDN’s edge, which is the correct choice for user-generated content whose dimensions are unknown at build time.

The critical architectural insight: <NuxtImg> is a URL generator with a pluggable backend, whereas vite-imagetools is a compile-time asset transformer with no runtime component. Nuxt gives you ergonomics and provider portability; imagetools gives you deterministic, hash-addressable output you can pair with a raw <picture> element for full art-direction control.

Nuxt and Vite build-time image pipeline Source images enter two parallel paths. The Nuxt path routes through NuxtImg/NuxtPicture to the IPX or a CDN provider. The Vite path routes a query-string import through vite-imagetools and Sharp. Both converge on generated picture markup and srcset delivered to the browser. Source asset hero.jpg / png in /assets <NuxtImg> / <NuxtPicture> modifiers, densities format=avif,webp ?w=400;800 &as=srcset import query import.meta.glob Transform layer IPX (Nuxt) — Sharp vite-imagetools — Sharp resize + re-encode content hash → filename or CDN URL (provider mode) Generated <picture> srcset + sizes width / height avif → webp → jpg served to browser component ergonomics · provider-portable encode once in CI · hash-addressable

Where @nuxt/image and vite-imagetools overlap

Both ultimately call Sharp/libvips and both can emit AVIF and WebP. The distinction is the interface. @nuxt/image exposes a declarative component whose props (width, sizes, densities, format, quality) are compiled into provider URLs and a srcset. vite-imagetools exposes an imperative module import whose query string (?w=400;800;1200&format=avif&as=srcset) is resolved to a plain string you place into markup yourself. Use the component when the design system owns the layout; use the import when you need byte-exact control over which widths exist, in what order the formats appear, and how the <picture> is assembled.

Configuration reference

The table contrasts the two systems’ knobs so you can map a requirement to the correct layer.

Requirement @nuxt/image mechanism vite-imagetools directive
Target formats format="avif,webp" prop or image.format config ?format=avif;webp (semicolons = multiple outputs)
Discrete widths width + sizes, or srcset via densities ?w=400;800;1200
Output as srcset string <NuxtImg sizes> computes it ?as=srcset
Output as <picture> metadata <NuxtPicture> renders it ?as=picture
DPR variants densities="1x 2x" ?w=400&dpr=1;2 or explicit widths
Quality quality="70" prop / image.quality ?quality=70
Fit / crop fit="cover" + modifiers ?fit=cover&position=attention
Backend provider: ipx, ipxStatic, cloudinary, cloudflare, imgix, netlify, vercel always local Sharp (build-time only)
Named reusable config image.presets in nuxt.config none — encode query per import
Runtime remote images domains allowlist + provider not supported (build-time inputs only)

Tradeoff: @nuxt/image presets centralise settings (presets: { hero: { modifiers: { format: 'avif', width: 1200 } } }) so a <NuxtImg preset="hero"> stays consistent site-wide, but a preset only helps if every author uses it. vite-imagetools has no preset concept — the query is copied per import — which is more repetitive but leaves zero ambiguity about what any given <img> will receive.

Step-by-step: @nuxt/image

Step 1 — Install and register the module

# @nuxt/image bundles IPX; sharp is pulled in transitively for local transforms.
npx nuxi module add image

Step 2 — Configure providers, presets, and defaults

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/image'],
  image: {
    // provider selects the backend. 'ipx' resolves images through the Nuxt
    // server at runtime (Node output); 'ipxStatic' pre-renders every variant
    // at build time for a fully static (nuxi generate) deploy — no server needed.
    provider: 'ipxStatic',

    // quality is the default encoder quality applied to every <NuxtImg>
    // that does not override it. 70–75 is the AVIF/WebP sweet spot.
    quality: 72,

    // format ordering here is the DEFAULT cascade for <NuxtPicture>.
    // avif first (smallest), webp second, original last — mirrors <picture>
    // source order, where the browser takes the first type it supports.
    format: ['avif', 'webp'],

    // screens maps named breakpoints to px so `sizes="sm:100vw lg:50vw"`
    // resolves without magic numbers scattered through templates.
    screens: { xs: 320, sm: 640, md: 768, lg: 1024, xl: 1280, xxl: 1536 },

    // presets are named modifier bundles referenced via <NuxtImg preset="hero">.
    // Centralising them prevents per-author drift in quality/format.
    presets: {
      hero: {
        modifiers: { format: 'avif', quality: 70, fit: 'cover' },
      },
      thumb: {
        modifiers: { format: 'webp', quality: 68, width: 240, height: 240, fit: 'cover' },
      },
    },

    // domains authorises remote hosts. Without an entry here, <NuxtImg>
    // pointed at a remote URL is passed through UNOPTIMISED (or 400s on
    // strict providers) — the module refuses to fetch arbitrary origins.
    domains: ['images.unsplash.com', 'cdn.example.com'],
  },
});

Step 3 — Use <NuxtImg> for single responsive images

<template>
  <!--
    <NuxtImg> renders ONE <img> with a computed srcset.
    width/height set the intrinsic ratio and reserve layout space,
    preventing the layout shift covered in the responsive-delivery guides.
    sizes drives which srcset candidate the browser picks; the keys
    (sm/lg) resolve against the `screens` map from nuxt.config.
    densities generates 1x and 2x candidates for each sizes breakpoint.
  -->
  <NuxtImg
    src="/hero.jpg"
    alt="Product hero on a neutral studio background"
    width="1200"
    height="675"
    sizes="sm:100vw md:100vw lg:1200px"
    densities="1x 2x"
    format="avif"
    quality="70"
    preset="hero"
    loading="eager"
    fetchpriority="high"
  />
</template>

Step 4 — Use <NuxtPicture> for multi-format art direction

<template>
  <!--
    <NuxtPicture> emits a full <picture> with one <source> per format
    in the `format` list, plus a fallback <img>. This is the component
    equivalent of hand-writing avif → webp → jpg source ordering.
    The `format` prop overrides the global cascade for this element.
  -->
  <NuxtPicture
    src="/gallery/frame-01.jpg"
    alt="Gallery frame one"
    :img-attrs="{ class: 'gallery-img', decoding: 'async' }"
    format="avif,webp"
    sizes="sm:100vw lg:50vw"
    width="960"
    height="540"
    loading="lazy"
  />
</template>

Warning: <NuxtImg format="avif"> renders a bare <img> whose src is AVIF with no fallback. If you need Safari 15 / legacy coverage you must use <NuxtPicture> (which adds the WebP and original sources) or list multiple formats. A single-format <NuxtImg> pointed at AVIF will break on any browser lacking AVIF decode. The precise Safari cutoff and fallback ordering are covered in configuring AVIF fallbacks for Safari 14.

Step-by-step: Vite asset pipeline

Step 1 — The raw Vite asset primitives

Before reaching for a plugin, Vite already gives you three ways to pull an image into the graph. Each resolves to a hashed, cache-busted URL at build time.

// 1. Default import → resolved, hashed URL string.
//    Vite copies the file to /assets/hero.<hash>.jpg and returns its path.
import heroUrl from './hero.jpg';

// 2. Explicit ?url suffix → same, but unambiguous when a plugin might
//    otherwise transform the import into something else (e.g. a component).
import heroExplicit from './hero.jpg?url';

// 3. import.meta.glob → map every file in a folder to a lazy loader.
//    eager: true inlines the resolved URLs at build time instead of
//    returning import() thunks — use for a known, finite gallery set.
const gallery = import.meta.glob('./gallery/*.jpg', {
  eager: true,
  query: '?url',
  import: 'default',
});
// gallery == { './gallery/01.jpg': '/assets/01.<hash>.jpg', ... }

Tradeoff: raw ?url imports give you hashing and cache-busting for free but do not resize or re-encode — the byte content is untouched. To generate multiple widths and AVIF/WebP variants you need vite-imagetools, which extends the query grammar with transform directives.

Step 2 — Add vite-imagetools for build-time transforms

// vite.config.js
import { defineConfig } from 'vite';
import { imagetools } from 'vite-imagetools';

export default defineConfig({
  plugins: [
    imagetools({
      // defaultDirectives runs for EVERY matching import even without a query,
      // so you can enforce a house style. Here: strip metadata + prefer avif.
      // It receives the URL and returns URLSearchParams.
      defaultDirectives: (url) => {
        if (url.searchParams.has('hero')) {
          return new URLSearchParams({
            format: 'avif;webp;jpg',   // three outputs, cascade order preserved
            w: '640;1280;1920',        // three widths per format
            as: 'picture',             // emit <picture> metadata object
          });
        }
        return new URLSearchParams();
      },
    }),
  ],
});

Step 3 — Import a transformed srcset in a component

// hero.js — Vue or Svelte both consume the same import result.
// The query is parsed by vite-imagetools at build time:
//   w=400;800;1200 → three resized copies
//   format=avif    → each re-encoded to AVIF
//   as=srcset      → module exports a ready-to-use srcset string
import heroAvif from './hero.jpg?w=400;800;1200&format=avif&as=srcset';
import heroWebp from './hero.jpg?w=400;800;1200&format=webp&as=srcset';

// heroAvif === "/assets/hero.<hash>-400w.avif 400w, ...800w, ...1200w"
export { heroAvif, heroWebp };
<!-- Vue single-file component using the imported srcset strings -->
<template>
  <picture>
    <source type="image/avif" :srcset="heroAvif" sizes="(max-width: 800px) 100vw, 1200px" />
    <source type="image/webp" :srcset="heroWebp" sizes="(max-width: 800px) 100vw, 1200px" />
    <img :src="fallback" alt="Hero" width="1200" height="675" decoding="async" />
  </picture>
</template>

<script setup>
import heroAvif from './hero.jpg?w=400;800;1200&format=avif&as=srcset';
import heroWebp from './hero.jpg?w=400;800;1200&format=webp&as=srcset';
import fallback from './hero.jpg?w=1200'; // single JPEG fallback URL
</script>

The exact query grammar, the as=srcset vs as=picture distinction, and how to verify the emitted widths are covered step-by-step in vite-imagetools responsive srcset generation. For the sizes math that makes these candidates resolve to the right pick, see mastering srcset and sizes for responsive layouts.

Parameter reference

Parameter System Meaning
provider @nuxt/image Backend that resolves image URLs: ipx (runtime), ipxStatic (build-time), or a CDN (cloudinary, cloudflare, imgix, netlify, vercel).
densities <NuxtImg> DPR candidates ("1x 2x") appended to the computed srcset for high-DPI screens.
sizes <NuxtImg>/<NuxtPicture> Breakpoint-to-width map (uses screens keys) that both drives srcset width choice and emits the sizes attribute.
preset <NuxtImg> Named modifier bundle from image.presets, keeping quality/format consistent.
domains nuxt.config Allowlist of remote hosts @nuxt/image may optimise; unlisted hosts are passed through unoptimised.
w=400;800 imagetools Semicolon list generates one output per width.
format=avif;webp imagetools Semicolon list generates one output per format.
as=srcset imagetools Export a srcset string (url 400w, url 800w).
as=picture imagetools Export { sources, img } metadata for building a <picture>.
defaultDirectives imagetools Function applying directives to every matching import — enforces a house encoding style.

Tradeoffs & failure modes

Failure mode Cause Fix
<NuxtImg format="avif"> breaks in Safari 15 Single-format <img>, no fallback source Use <NuxtPicture format="avif,webp"> or a multi-format cascade
Remote image served full-size / unoptimised Host missing from image.domains Add the origin to domains, or use a CDN provider that owns that host
nuxi generate ships a Node server accidentally provider: 'ipx' needs a runtime; static export needs ipxStatic Set provider: 'ipxStatic' for fully static hosting
imagetools import returns the original file unchanged Query omitted or plugin ordered after another that claims the import Confirm the ?w=… query is present and imagetools() runs early in plugins
Only one width in the emitted srcset Used w=800 (single) instead of w=400;800;1200 Use semicolon-separated widths
AVIF listed after WebP in <source> order Wrong format order in prop/config List avif before webp so the browser prefers the smaller format
Encode step balloons CI time AVIF at high effort across many widths × formats Cap widths to the breakpoints you actually use; lower quality; cache the transform output between CI runs

Warning: import.meta.glob with eager: false returns dynamic import() functions, not URLs. If you drop the returned object straight into a template expecting strings, every image slot renders [object Promise]. Set eager: true (and import: 'default') when you need the resolved URLs synchronously, or await the loader when lazy-loading a large gallery.

Debugging & validation

Inspect what the build actually emitted

# After `nuxt build` or `vite build`, list the generated derivatives.
# You should see one file per width × format combination, each hashed.
find dist -type f \( -name '*.avif' -o -name '*.webp' \) | sort

# Confirm a specific source produced the widths you asked for:
find dist -name 'hero.*-*.avif' -printf '%f  %s bytes\n'

Confirm the rendered markup and negotiated format

# Fetch a built page and extract the generated <picture>/srcset so you can
# verify format order (avif before webp) and that width descriptors exist.
curl -s https://staging.example.com/ | grep -oE '<(picture|source|img)[^>]*>' | head -n 20

# Confirm the AVIF variant is served with the right Content-Type.
# A wrong type here means IPX/your host is not registering image/avif.
curl -sI https://staging.example.com/_ipx/f_avif/hero.jpg | grep -i content-type

Tradeoff: IPX in runtime mode (provider: 'ipx') caches transforms on first request, so the first hit to a new size is slow (a cold Sharp encode) while subsequent hits are fast. On a fresh deploy this shows up as elevated TTFB on cold images. Pre-warm critical sizes, or switch to ipxStatic so every variant is materialised in CI and the origin only serves static bytes — the same immutable-caching benefits described in best practices for setting max-age on CDN media assets.