This document explains how the different image-handling pieces fit together in the current codebase.
There are two distinct image pipelines, both converging on the same
<Picture> rendering primitive:
- Static assets bundled under
src/assets/images/— resolved at build time by Vite andvite-imagetools, surfaced to the client via server load functions. - Remote / arbitrary images — optimized at request time by Netlify's
image CDN via
NetlifyImage.svelte.
Both produce a Picture-shaped object ({ sources, img }) that the shared
Picture.svelte component renders into a <picture> tag with avif +
webp <source>s and a raster fallback.
Defined in vite-imagetools and re-exported from src/lib/types.ts:
type Picture = {
sources: Record<'avif' | 'webp' | string, string> // format → srcset
img: { src: string; w: number; h: number }
}This is the single shape every image path eventually produces. A loose
variant (LoosePicture, also defined in src/lib/types.ts) makes img.w /
img.h optional, so callers that don't know intrinsic dimensions (e.g.
NetlifyImage) don't have to fabricate them. LoosePicture is the type
accepted by Picture.svelte, Image.svelte, and NetlifyImage.svelte.
Widths, formats, meta-image width, and quality are centralized here so the
Vite ?picture and ?meta pipelines and NetlifyImage.svelte stay in
sync. The file has no SvelteKit dependencies, so it can be imported from
vite.config.ts as well as app code.
This module is .server-only. It uses three import.meta.glob calls over
src/assets/images/**/*:
| Glob | Query | Returns |
|---|---|---|
IMAGE_URLS |
?url |
The Vite-resolved URL string for any asset |
PICTURES |
?picture |
A Picture object (avif/webp/jpeg/png, widths 400–2400) for raster images |
METADATA_IMAGE_URLS |
?meta |
A single downscaled JPEG for social/SEO meta tags |
It exposes three resolvers:
resolveImageUrl(path)— returns the Vite URL for an asset path, or the path unchanged if it isn't a bundled asset (e.g. an external URL).resolvePicture(path)— returns thePicturefor an asset path, ornull.getPostMetaImageUrl(path)— returns the meta-image URL (the 1200px JPEG) for a post's frontmatterimage, falling back toresolveImageUrl.
Keeping this module server-only is the key constraint: the large glob map
never ships to the client. Server load functions call these resolvers and
pass only the resulting URLs/Picture objects across the boundary.
Markdown posts (src/posts/*.md and l10n-cage/md/<locale>/*.md) can
reference images in two ways:
- The frontmatter
imagefield (the banner). - Inline
images in the body.
A remark plugin registered in svelte.config.js. It walks the MDAST tree,
collects every image node's URL, dedupes them, and writes the list to
vFile.data.fm._images. mdsvex then exports that as metadata._images on
the compiled module. It deliberately ignores HTML <img> tags and the
frontmatter image (the latter is already available as metadata.image).
imagesForPost(locale, slug) is called from +page.server.ts. It:
- Imports the markdown module (via the shared
markdown.tshelper) to readmetadata.imageandmetadata._images. - Resolves the banner (
metadata.image) into aResolvedImage{ picture, assetUrl }—pictureisnullfor non-asset URLs. - Resolves each body image in
metadata._imagesthe same way. - Computes the meta image URL via
getPostMetaImageUrl.
Returns { banner, metaImageUrl, pictures }, where pictures is a
Record<src, ResolvedImage> keyed by the original markdown src.
+page.server.tsreturns{ cssUrls, pictures, banner, metaImageUrl }.+page.sveltecallssetPostPictures(data.pictures)to push the map into Svelte context, and renders the banner via<Image>usingdata.banner.picture/data.banner.assetUrl.- The markdown renderer's custom
<img>component (src/lib/components/custom/img.svelte) reads the map back out withgetPostPictures()[src]and forwardspicture+ resolvedassetUrlto<Image>. Images not in the map (external URLs) fall back to<Image>'s plain<img>branch.
This is what keeps the glob resolver out of the client bundle: the client
only ever sees the already-resolved Picture objects and URLs.
The shared primitive. Takes a LoosePicture and renders:
<picture>
{#each Object.entries(picture.sources) as [format, srcset]}
<source type={`image/${format}`} {srcset} {sizes} />
{/each}
<img src={picture.img.src} width={picture.img.w} height={picture.img.h} ... />
</picture>The general-purpose entry point. Accepts either a pre-resolved picture
(a LoosePicture) or a plain src (external URL / already-resolved
asset URL). When a picture is present it delegates to <Picture>;
otherwise it renders a bare <img>.
It also handles the aspectRatio + object-fit: cover case: when a target
aspect ratio is supplied, it scales the sizes attribute by
max(1, imgAr / targetAr) so the browser picks a source wide enough to
cover the container after cropping.
For remote / arbitrary images that aren't bundled assets. It builds
/.netlify/images?url=...&w=...&q=...&fm=...&fit=... URLs for each width
in avif and webp srcsets plus a JPEG fallback, assembles them into a
Picture-shaped object, and hands it to <Picture>. If the optimized
variants fail to load it falls back to the raw src via an onerror
handler.
vite.config.ts registers a vite-imagetools instance with a
defaultDirectives hook. Two query shorthands are supported, both driven by
image-config.ts:
?picture→as=picturewithimageFormatsandimageWidths(for responsive<picture>rendering).?meta→ a singlemetaImageWidth-wide JPEG atimageQuality(for social/SEO meta tags).
The width list matches NetlifyImage's defaults so the two pipelines emit
comparable srcsets.
src/routes/api/news/+server.ts resolves the picture for each internal
news item's image (when it points at a static asset) using
resolvePicture, and attaches it to the NewsItem payload. This keeps the
news listing client free of the glob resolver for the same reason as the
post pipeline. External items (Substack RSS, press coverage) carry only the
raw image URL — picture stays null for those.
LatestNews.svelte fetches /api/news and renders each NewsItem via
NewsCard.svelte, which picks the rendering component based on the item's
source:
- Internal items carry a server-resolved
picture, so they go through<Image>(which delegates to<Picture>). - External items (Substack, press) only have a raw URL, so they go
through
<NetlifyImage>.
| You have… | Use |
|---|---|
| A bundled asset path in a server load | resolvePicture / resolveImageUrl from $lib/image.server |
| A post banner / body image | imagesForPost from routes/[slug]/post-images.server |
| A remote or arbitrary URL | <NetlifyImage src={url} … /> |
A resolved Picture (or a plain URL fallback) |
<Image picture={…} src={…} /> |
Just need to render a Picture object |
<Picture picture={…} /> |
A static asset imported directly in a .svelte file |
import foo from '$assets/…/foo.jpg?picture' → <Image picture={foo} /> |