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.
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.
Related
- Vite imagetools responsive srcset generation — the exact query directives and verification steps for a build-time AVIF/WebP srcset
- Next.js Image Component Optimization — the equivalent component-driven pipeline in the React/Next ecosystem
- Astro Image and Picture Components — a third build-time model using astro:assets and the Sharp service
- AVIF vs WebP Compression Benchmarks — the file-size and encode-time data behind choosing your
formatcascade - Mastering srcset and sizes for responsive layouts — how the browser picks a candidate from the srcset these tools emit
- Framework & Build-Tool Media Integration — parent section covering image handling across every major framework and bundler