Two Pipelines, Two Purposes

You have a working client-side image pipeline. A user drops a 4MB JPEG into your dropzone, a Web Worker pool decodes it once and emits AVIF, WebP, three responsive widths, and a base64 LQIP, the main thread never stutters, and a LazyImage.svelte component renders the result with the right priority hints and a blur-up cross-fade. That pipeline is the right answer for one specific category of image: the kind that arrives after deployment.

It is the wrong answer for another category that lives entirely inside your repository.

The hero image on your marketing page, the diagrams in your documentation, the logo in the navigation bar, the screenshots in your blog posts, none of these are user uploads. They are committed files. You know their dimensions when you write the build script. You know their content will not change between deployments.

You know the user’s browser does not need to do any of the format negotiation, resizing, or encoding work that the runtime pipeline performs, because all of that work can be done once, ahead of time, and the results can be served as static files from the moment the page loads.

That is what @sveltejs/enhanced-img exists to do. It is a build-time preprocessor that takes images you import directly into Svelte components and emits the same AVIF/WebP/srcset/sizes/aspect-ratio output you have been generating manually, but with zero runtime cost and zero JavaScript involvement at the user end.

This lesson is about knowing which tool to reach for in which situation. The runtime pipeline you spent eight lessons building is not being replaced. It is being joined by a second pipeline that handles a different class of image, with a different set of constraints, and a different set of tradeoffs. By the end you will have a clear decision matrix, a working enhanced:img setup in this codebase, and a mental model for the third pipeline option - server-side Sharp processing for CMS-fed URLs - that sits between the other two.

The Decision Matrix

Before any code, the matrix. The right pipeline for a given image is determined almost entirely by where the image comes from. Three rows, three answers.

User uploads → client-side @jsquash pipeline. This is the territory you have been in for the entire track. Images arrive at runtime, often megabytes in size, in unpredictable formats. The browser has the decoder, the encoder, and the worker pool to handle them, and the user has the patience to wait a couple of seconds for the upload UI to spin. Generating variants on the user’s machine offloads cost from your servers and avoids paying for an image-processing service. The previous nine lessons cover this end-to-end.

Committed repository assets → enhanced:img. Logos, hero images, blog post inline images, illustrations - anything that lives in your src/ directory and is checked into git. These have a known set of dimensions and a known content at build time, so doing the work then is strictly cheaper than doing it later. enhanced:img writes the variants to your build output and emits the <picture> markup automatically when you write <enhanced:img src="./hero.jpg" />. The user’s browser does no format negotiation, no resizing, no encoding. Just static GET requests for files that have been pre-rendered.

CMS URLs and remote dynamic content → server-side Sharp (or a CDN service). This is the third row, and it is the row most teams overlook because they assume one of the other two pipelines covers it. It does not. A CMS like Contentful, Strapi, or Sanity returns image URLs that point to a third-party origin, and your build process never sees the bytes. You cannot use enhanced:img because there is no local file to import.

You could use the client-side pipeline by re-uploading every CMS image into your own dropzone, but that is absurd: you would be paying decode cost on the user’s device for an image whose source is already on a perfectly good server. The right answer is a server-side handler that fetches, processes, and caches the image on demand, usually using sharp. Many teams skip this entirely and let the CMS serve images at their original size, which is exactly the regression the rest of this track is meant to prevent.

The shape of the decision is therefore: can I touch this image at build time? If yes, build-time. If no, can I touch it on my server? If yes, server-time. If no, client-time. Each step down the ladder costs more at request time, so the goal is always to handle the image as early in the pipeline as possible.

What follows is a deep dive on each row, starting with the new piece - enhanced:img - and finishing with how the three pipelines coexist in a single SvelteKit app.

What enhanced:img Actually Does

@sveltejs/enhanced-img is a Vite plugin that registers a custom Svelte preprocessor. When the preprocessor encounters an <enhanced:img> element with a relative src attribute, it does several things at compile time:

  1. Resolves the import the same way Vite resolves any other asset import. The path you write must point to a file Vite can find, with one of the supported source formats (JPEG, PNG, WebP, AVIF, GIF, TIFF).
  2. Reads the image and extracts metadata including the intrinsic dimensions, which it bakes into width and height attributes on the emitted <img> so the browser can reserve layout space (no CLS).
  3. Generates a family of size variants at sensible breakpoints, defaulting to a doubling sequence that covers common DPR cases up to the original’s intrinsic width. You can override the widths if you need to.
  4. Encodes each variant in AVIF and WebP, in addition to keeping a fallback in the original format.
  5. Emits a <picture> element with <source> entries ordered AVIF → WebP → original, plus a terminal <img> carrying the dimensions, alt text, and any other attributes you passed through.

This is, structurally, the same output that ResponsiveImage.svelte from Lesson 08 produces, except the file generation happens during vite build instead of during a user upload. The runtime cost is exactly zero JavaScript and exactly the bytes of one image variant, the one the browser picked from the srcset.

The performance characteristics are also different in a quietly important way. Because the variants are static files written to your build output, your CDN can cache them aggressively with long-lived Cache-Control headers.

A returning visitor pays no network cost for the hero image at all. A user upload variant cannot benefit from this because the URL is unique per upload; a build-time variant has a stable, hash-suffixed URL that survives deployments.

Installing and Wiring It Up

In a SvelteKit project, installation is a single dependency and a single config edit.

pnpm add -D @sveltejs/enhanced-img

Register the plugin in vite.config.ts before the SvelteKit plugin. The order matters because enhanced:img is itself a Vite plugin that injects a Svelte preprocessor; it needs to be in the chain before SvelteKit’s preprocessor compiles components.

// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite'
import { enhancedImages } from '@sveltejs/enhanced-img'
import { defineConfig } from 'vite'

export default defineConfig({
	plugins: [
		// Order matters: enhancedImages() must precede sveltekit().
		enhancedImages(),
		sveltekit()
	]
})

That is the entire setup. There is no per-image configuration file, no separate build step, no asset manifest to maintain. From this point on, any component that uses <enhanced:img> triggers the preprocessor for the imports it references.

Basic Usage

The simplest case is a static image checked into your repository. Drop a file into the assets directory of your choice - common conventions are src/lib/assets/images/ or co-locating the image with the component that uses it - and reference it by relative path.

<!-- src/routes/+page.svelte -->
<script lang="ts">
	// No import statement is required for the default form. enhanced:img resolves
	// the relative path itself at compile time.
</script>

<enhanced:img
	src="$lib/assets/images/hero.jpg"
	alt="A mountain range at sunset"
	sizes="(max-width: 768px) 100vw, 800px"
/>

The compiled output of that single line is a complete <picture> element with three or four widths in two formats, intrinsic dimensions baked in, and the sizes attribute applied to each <source>. You can verify it by inspecting the generated HTML in DevTools or by running vite build and looking at the resulting .html files.

A more controlled form, useful when you need to pass the imported asset through props or use it in a more complex layout, is to import the image explicitly and pass it as a binding:

<!-- src/lib/components/HeroSection.svelte -->
<script lang="ts">
	import heroImage from '$lib/assets/images/hero.jpg?enhanced'
</script>

<enhanced:img src={heroImage} alt="A mountain range at sunset" sizes="..." />

The ?enhanced query parameter tells Vite to run the file through the enhanced-img transform and return a structured object containing all the variant URLs and metadata. This is the form to use when you need the same image in multiple components, or when you want TypeScript to verify that the image exists at build time.

Customising Widths and Formats

The default width set covers most cases, but the preprocessor accepts overrides via the ?w query parameter. You can specify exactly the widths you want, comma-separated, in the import URL.

<script lang="ts">
	// Generate exactly the widths used by ResponsiveImage.svelte from Lesson 08.
	// Keeping the build-time and runtime pipelines aligned on widths means a
	// page that mixes both pipelines never has a 600w gap between candidates.
	import heroImage from '$lib/assets/images/hero.jpg?enhanced&w=400;800;1600'
</script>

<enhanced:img
	src={heroImage}
	alt="A mountain range at sunset"
	sizes="(max-width: 768px) 100vw, (max-width: 1200px) 66vw, 800px"
/>

This is one of the small wins of having the same IMAGE_WIDTHS constant from Lesson 08 available everywhere. Both pipelines emit the same set of breakpoints, so the visual quality is consistent regardless of whether a particular image came from the build or from a user upload. A page that mixes them does not betray the difference.

The plugin also supports a ?format parameter for forcing specific output formats, but the default (AVIF + WebP + original) is almost always what you want. The exception is when you have a transparency-critical PNG (a logo on a varied background, for example) where you want to preserve the alpha channel correctly; in that case forcing PNG fallback is sensible.

Using It Alongside the Runtime Pipeline

Here is where the two pipelines have to coexist in the same component tree, and where developers tend to overthink the integration.

The honest answer is that they do not need to integrate at all. They produce the same output shape - a <picture> element with AVIF/WebP/fallback and a srcset of widths - but they consume entirely different inputs. There is no shared state, no shared component, no shared cache. The cleanest architecture is to have two components with similar APIs, one for each pipeline, and pick at the call site based on the image source.

<!-- src/routes/products/[slug]/+page.svelte -->
<script lang="ts">
	import LazyImage from '$lib/components/LazyImage.svelte'
	import HeroBanner from '$lib/assets/images/product-hero.jpg?enhanced&w=400;800;1600'

	let { data } = $props()
	// data.product.image: a runtime image with baseUrl, lqip, etc. (from upload pipeline)
</script>

<!-- The marketing banner is committed to the repo: enhanced:img is correct here. -->
<enhanced:img
	src={HeroBanner}
	alt="Spring sale - up to 40% off"
	sizes="100vw"
	fetchpriority="high"
	loading="eager"
/>

<!-- The product image is a user upload: the runtime pipeline is correct here. -->
<LazyImage
	src={data.product.image.baseUrl}
	alt={data.product.image.alt}
	width={data.product.image.width}
	height={data.product.image.height}
	lqip={data.product.image.lqip}
	sizes="(max-width: 768px) 100vw, 800px"
	priority={false}
/>

<!-- Gallery thumbnails, also user uploads: same component. -->
{#each data.product.gallery as image (image.id)}
	<LazyImage
		src={image.baseUrl}
		alt={image.alt}
		width={image.width}
		height={image.height}
		lqip={image.lqip}
		sizes="(max-width: 640px) 50vw, 25vw"
	/>
{/each}

Notice the asymmetry. The enhanced:img element does not take an LQIP prop. It does not need one - the variants are static files served from a CDN-cached origin with Cache-Control headers measured in years. The hero image is in the user’s browser cache after the first visit, and even on a cold cache the AVIF version of a marketing banner is small enough that the time-to-paint is dominated by network latency rather than transfer size. A blurred placeholder for an image that loads in 200ms on a 4G connection is overhead, not a feature.

The runtime pipeline keeps its LQIP because user-uploaded product images are unique URLs with no shared cache benefit, and the gallery is potentially long enough to need real lazy loading. The two components solve different problems. They look similar at the call site, which is a feature, not an accident - but they are not interchangeable.

When to Reach for Server-Side Sharp

Now the third row of the matrix, which enhanced:img does not solve.

A SvelteKit blog that pulls images from a headless CMS gets a JSON payload like:

{
	"title": "Spring 2026 collection",
	"heroImage": "https://images.contentful.com/abc123/heroes/spring-2026.jpg?w=2400",
	"body": "..."
}

The image lives on Contentful’s CDN. Your build process does not have access to the bytes, so enhanced:img cannot touch it. You also do not want to send the user a 2400-pixel JPEG when they are reading on their phone, because everything in this track has been about not doing that.

The standard answer is a SvelteKit endpoint that proxies, transforms, and caches the image at request time, using sharp (the standard server-side image library for Node) under the hood.

pnpm add sharp
// src/routes/api/cms-image/+server.ts
import type { RequestHandler } from './$types'
import sharp from 'sharp'

// Whitelist of upstream origins. Never proxy arbitrary URLs - that turns your
// server into an open image rewriter and a potential SSRF vector.
const ALLOWED_ORIGINS = ['images.contentful.com']

const SUPPORTED_FORMATS = ['avif', 'webp', 'jpeg'] as const
type Format = (typeof SUPPORTED_FORMATS)[number]

export const GET: RequestHandler = async ({ url, setHeaders }) => {
	const src = url.searchParams.get('src')
	const widthParam = url.searchParams.get('w')
	const formatParam = (url.searchParams.get('f') ?? 'webp') as Format

	if (!src) return new Response('missing src', { status: 400 })

	// Validate the upstream origin before fetching anything.
	const upstream = new URL(src)
	if (!ALLOWED_ORIGINS.includes(upstream.host)) {
		return new Response('upstream not allowed', { status: 400 })
	}

	if (!SUPPORTED_FORMATS.includes(formatParam)) {
		return new Response('unsupported format', { status: 400 })
	}

	const targetWidth = widthParam ? Math.min(2400, parseInt(widthParam, 10)) : undefined

	// Fetch the upstream bytes. In production, put this behind a server-side cache
	// (KV store, Redis, or filesystem) keyed on src+width+format.
	const upstreamRes = await fetch(src)
	if (!upstreamRes.ok) {
		return new Response('upstream fetch failed', { status: 502 })
	}
	const buffer = Buffer.from(await upstreamRes.arrayBuffer())

	// Sharp does decode → resize → encode in one pipeline, similar to the
	// jSquash flow on the client but server-side and considerably faster.
	let pipeline = sharp(buffer)
	if (targetWidth) {
		pipeline = pipeline.resize({ width: targetWidth, withoutEnlargement: true })
	}

	const output = await pipeline.toFormat(formatParam, { quality: 80 }).toBuffer()

	setHeaders({
		'Content-Type': `image/${formatParam}`,
		// Long cache. The URL includes width + format, so any change yields a
		// different URL and a fresh entry. Identical URL → identical bytes forever.
		'Cache-Control': 'public, max-age=31536000, immutable'
	})

	return new Response(output)
}

Then a small utility to construct URLs against this endpoint, and a RemoteImage.svelte component that mirrors the API of LazyImage:

// src/lib/cms-image.ts
const WIDTHS = [400, 800, 1600] as const
const FORMATS = ['avif', 'webp'] as const

export function buildCmsSrcset(remoteUrl: string, format: (typeof FORMATS)[number]) {
	return WIDTHS.map(
		(w) => `/api/cms-image?src=${encodeURIComponent(remoteUrl)}&w=${w}&f=${format} ${w}w`
	).join(', ')
}
<!-- src/lib/components/RemoteImage.svelte -->
<script lang="ts">
	import { buildCmsSrcset } from '$lib/cms-image'

	interface Props {
		src: string // remote CMS URL
		alt: string
		width: number
		height: number
		sizes?: string
		priority?: boolean
	}

	let { src, alt, width, height, sizes = '100vw', priority = false }: Props = $props()

	const avifSrcset = $derived(buildCmsSrcset(src, 'avif'))
	const webpSrcset = $derived(buildCmsSrcset(src, 'webp'))
	const fallbackSrc = $derived(`/api/cms-image?src=${encodeURIComponent(src)}&w=800&f=webp`)
</script>

<picture>
	<source type="image/avif" srcset={avifSrcset} {sizes} />
	<source type="image/webp" srcset={webpSrcset} {sizes} />
	<img
		src={fallbackSrc}
		{alt}
		{width}
		{height}
		loading={priority ? 'eager' : 'lazy'}
		fetchpriority={priority ? 'high' : 'auto'}
		decoding="async"
	/>
</picture>

The first request for a given URL+width+format combination is slow - it has to fetch from the upstream CMS, decode, resize, encode, and write the response. Subsequent requests hit the CDN’s cached copy of your endpoint’s response and never touch your server. With a long Cache-Control: max-age=31536000, immutable header (which is safe because the URL fully determines the output bytes), the cache hit ratio in production is typically over 99% within the first few hours of any new content going live.

This third pipeline is also where it pays to add a server-side cache before the CDN - a Cloudflare KV store, a Redis instance, or even a temp directory - to absorb the cold-cache stampede when you publish a new article and a hundred users hit it within seconds. The CDN fans out, but each unique URL still hits your origin once, and sharp’s decode+encode for a 2400-pixel JPEG is in the order of 100–300ms per request. A cheap server-side cache turns those into near-zero file reads.

LQIP for this pipeline is also doable: extend the endpoint with a &lqip=1 parameter that returns the base64 string instead of bytes, and call it once at load time in your +page.server.ts. The placeholder string then lives on the props, exactly like the user-upload pipeline, and LazyImage-style cross-fading works identically.

A Realistic Project Layout

Putting all three pipelines into a single SvelteKit project, the file layout settles into something like:

src/
├── lib/
│   ├── assets/
│   │   └── images/                 # build-time: enhanced:img source files
│   │       ├── hero.jpg
│   │       └── logo.svg
│   ├── components/
│   │   ├── ResponsiveImage.svelte  # Lesson 08 - runtime, no LQIP
│   │   ├── LazyImage.svelte        # Lesson 09 - runtime, with LQIP
│   │   └── RemoteImage.svelte      # this lesson - server-side via Sharp
│   ├── workers/
│   │   └── image-optimizer.worker.ts  # Lessons 06–09 - client-side decode/encode
│   ├── config/
│   │   └── image-pipeline.ts       # IMAGE_WIDTHS shared by all three
│   └── cms-image.ts                # this lesson - RemoteImage URL builder
└── routes/
    └── api/
        └── cms-image/
            └── +server.ts          # this lesson - Sharp transform endpoint

Three components, three sources of input, one shared IMAGE_WIDTHS constant. The shared constant is the only piece of code that physically links the pipelines, and it does so for a single reason: to keep the breakpoints consistent so a page that mixes pipelines does not have visible quality steps between adjacent images.

The decision at every call site is the same one-liner question: where does this image come from? Repository → <enhanced:img>. User upload → <LazyImage>. CMS → <RemoteImage>. There is no overlap, no fallback path, no clever wrapper that picks the right pipeline at runtime. The wrapper would be a leaky abstraction; the explicit choice is clearer and faster.

Common Mistakes and Anti-Patterns

Using enhanced:img for user uploads

You cannot. The preprocessor only sees imports it can resolve at build time. Trying to pass a runtime URL through <enhanced:img src={runtimeUrl}> either fails the build or, worse, silently falls through to a plain <img> with no optimisation. Stick to the runtime pipeline for upload-sourced images.

Re-uploading CMS images through the dropzone

Some teams, on discovering that enhanced:img does not handle CMS URLs, decide to download every CMS image into their own storage and run it through the upload pipeline. This is twice the storage cost, twice the egress bandwidth, and a synchronisation problem (what happens when the editor updates the image in the CMS?). The server-side Sharp endpoint is the right tool here. Do not duplicate storage.

Forgetting to whitelist origins on the Sharp endpoint

A naive image-proxy endpoint that fetches whatever URL it receives is an open SSRF vulnerability. A user can craft requests that probe internal services, fetch from arbitrary origins, or trigger denial-of-service via huge upstream files. The ALLOWED_ORIGINS whitelist is non-negotiable, and the size limit on the upstream fetch is similarly non-negotiable in production.

Skipping Cache-Control on the Sharp endpoint

Without a long Cache-Control header, every request for the same transformed URL hits your server fresh. On a moderately busy site this is the difference between a $5/month VPS and a $200/month one. The URL fully determines the output bytes (because format and width are query parameters), so immutable with a year-long max-age is correct.

Mixing widths between the three pipelines

If your build-time pipeline generates 400/800/1600 and your runtime pipeline generates 480/960/1920, a page that uses both ends up with subtle quality mismatches at certain viewport sizes. Pick one set of widths in src/lib/config/image-pipeline.ts and have all three pipelines use it. The ?w=400;800;1600 syntax on enhanced:img, the IMAGE_WIDTHS array in the worker, and the WIDTHS constant in cms-image.ts should all come from the same source of truth.

Treating enhanced:img as a magic black box

The preprocessor reads files from disk during the build. If you have a thousand large images in src/lib/assets/images, your build time will rise accordingly - possibly into multiple minutes. This is not a bug; it is the cost of doing the encoding work once instead of on every request. If your build slows down meaningfully, audit which images actually need to be in the build pipeline. Editorial content with hundreds of images per post probably wants the CMS+Sharp pipeline instead.

Performance and Scaling Considerations

The three pipelines have very different scaling profiles, and recognising this is the practical payoff of having a clear decision matrix.

enhanced:img scales with the number of images in your repository times the number of variants per image. For a site with a few dozen committed images, this is invisible. For a documentation site with hundreds of inline diagrams and screenshots, the build time becomes a real consideration, and you should run the preprocessor in CI rather than on every developer’s laptop. The output is purely static, so once the build is done, runtime cost is zero forever.

The runtime @jsquash pipeline scales with the number of images uploaded per unit time, on the user’s hardware. It does not consume your server resources at all. The cost it does consume - the user’s CPU and battery - is generally acceptable because uploads are an explicit user action they expect to take a moment. The pipeline does not, however, work for read-heavy gallery views from existing data; that is the runtime display pipeline, which only renders pre-encoded variants and is essentially free.

The server-side Sharp pipeline scales with unique URL+width+format combinations across your entire CMS catalogue. With a CDN in front, the cost per request drops to near zero after the first hit, but the memory and CPU footprint of the origin server during cache-miss spikes can be significant. sharp itself is fast (it is a native binding to libvips), but a sudden traffic spike on a fresh article can drive transient CPU above 80%. Provision accordingly, and consider pre-warming the cache by hitting common width/format combinations in a build hook after a CMS publish webhook.

The combined effect of doing all three correctly is a SvelteKit application where every image - regardless of origin - is delivered as a right-sized AVIF or WebP, with srcset, sizes, intrinsic dimensions, and the right priority hints, and no part of that delivery costs the user any avoidable byte or any avoidable millisecond. The plumbing is more elaborate than a single one-size-fits-all approach, but each pipeline does its job cheaply and predictably, and the boundaries between them are sharp.

Conclusion

@sveltejs/enhanced-img is the SvelteKit team’s answer to the build-time half of image optimisation. It does the same kind of work the @jsquash Worker pipeline does - decode, resize, encode, emit responsive markup - but it does it once, at build time, for images you have committed to your repository. The runtime cost is zero JavaScript and the bandwidth savings are immediately visible in a Lighthouse audit.

The decision matrix is the heart of this lesson. User uploads stay on the client-side pipeline, because the bytes only exist in the browser at the moment the user picks them. Repository assets go through enhanced:img, because doing the work at build time is strictly cheaper than doing it later. CMS-fed URLs go through a server-side sharp endpoint, because they cannot be touched at build time but can be cheaply transformed on demand and cached aggressively.

A production SvelteKit application typically uses all three. They are not competing; they are covering different rows of the same problem. The shared IMAGE_WIDTHS constant, the shared <picture>-element output shape, and the shared call-site ergonomics across LazyImage, RemoteImage, and enhanced:img mean a developer adding a new image to the codebase has exactly one decision to make: where is this image coming from? The rest is mechanical.

The next lesson is about discoverability. You have built a sophisticated image stack, but performance is only the floor of image quality - the ceiling is whether every image is legible to screen readers, search engines, and the social-card generators that index your content. Lesson 12 covers semantic file naming, the craft of writing alt text, structured data with ImageObject, and the Svelte 5 enforcement pattern that makes accessibility the default at the upload UI rather than an afterthought.

Key Takeaways

  • The right pipeline for an image is determined by where the image comes from. Repository → enhanced:img. User upload → @jsquash runtime. CMS / remote → server-side sharp endpoint.
  • @sveltejs/enhanced-img is a Vite plugin that compiles <enhanced:img> into a full <picture> element with AVIF/WebP/fallback, intrinsic dimensions, and a srcset, all generated at build time.
  • The ?enhanced query parameter on a Vite import is the explicit form; <enhanced:img src="./hero.jpg" /> is the shorthand. Both produce the same compiled output.
  • A ?w=400;800;1600 parameter pins the build-time widths to the same values used by the runtime pipeline, so a page mixing both has consistent visual quality.
  • enhanced:img does not need LQIP because static files cache forever on the CDN; the runtime pipeline does need LQIP because upload URLs are unique and have no shared cache benefit.
  • The third row of the matrix - server-side sharp for CMS URLs - is the one teams most often skip. Without it, dynamic content bypasses every optimisation in this track.
  • The Sharp endpoint must validate upstream origins (SSRF protection) and set a long Cache-Control so that the CDN absorbs every repeat request.
  • A shared IMAGE_WIDTHS constant in src/lib/config/image-pipeline.ts keeps all three pipelines in sync. Mismatched widths produce subtle quality steps that are difficult to debug after the fact.

Further Reading

See Also