Skip to main content

Image format catalog

The complete reference for how images flow through Flexweg CMS — from upload to public URL.

For theme-author guidance on declaring formats see Creating themes → Image variants. For the visitor-facing usage in editor blocks see Editor → Core blocks.

The pipeline

User picks file in Media library

Browser-side processing in services/imageProcessing.ts

For each declared format:
• createImageBitmap(file)
• Canvas resize/crop per `fit`
• Encode as outputFormat (typically WebP) at quality

For each variant: upload to media/<yyyy>/<mm>/<slug>-<hex>/<variant>.webp

Firestore doc media/{id} written with formats map

Everything happens in the user's browser. No server, no third-party image API.

Where formats come from

Three sources merge into the format catalog used per upload:

Active theme's imageFormats.formats

The user-facing variants the theme expects. Declared in the theme's manifest:

imageFormats: {
inputFormats: [".jpg", ".png", ".webp", ".gif"],
outputFormat: "webp",
quality: 80,
formats: {
small: { width: 480, height: 480, fit: "cover" },
medium: { width: 800, height: 800, fit: "cover" },
large: { width: 1600, height: 900, fit: "cover" },
},
defaultFormat: "medium",
}

Admin-only formats (always added)

Used by the media library UI itself. Declared in src/services/imageFormats.ts:

NameSizePurpose
admin-thumb100×100 coverMedia library grid
admin-preview400×400 coverMedia library detail view

Built-in fallback (when no theme is active)

If somehow no theme is active (very unusual), the upload pipeline uses a minimal fallback set with just a medium 800×800 format. The publisher won't actually be able to render anything in that state — this is mostly a safety net.

What gets generated

For every uploaded image, every declared format runs through the resize pipeline. So a default theme upload generates:

small.webp (480×480)
medium.webp (800×800)
large.webp (1600×900)
admin-thumb.webp (100×100)
admin-preview.webp (400×400)

Five variants, all WebP. Stored under one folder per upload:

/media/2026/04/launch-photo-1a2b3c/
├── small.webp
├── medium.webp
├── large.webp
├── admin-thumb.webp
└── admin-preview.webp

The 6-character hex suffix (-1a2b3c above) is appended by normalizeMediaSlug() to make collisions impossible by construction. So two uploads of launch-photo.jpg get distinct folders.

Fit modes

type ImageFit = "cover" | "contain";

Browser-side implementation:

  • cover — crop to fill the box. Image is scaled to be at least as wide AND tall as the target, then cropped from centre.
  • contain — fit inside the box. Image is scaled to fit within the target dimensions; aspect ratio preserved; possibly with letterboxing transparent pixels.

Sharp's other fit modes (inside, outside, fill) aren't supported in the browser implementation. If you need them, do server-side resizing externally before uploading.

Output formats

outputFormat: "webp" | "jpeg" | "png"
  • WebP — recommended default. ~25-35% smaller than JPEG at equivalent visual quality. Universally supported.
  • JPEG — for photo-heavy sites where you want maximum compatibility. Larger files; no transparency.
  • PNG — lossless. Use only for logos / line art / images with sharp edges. Files are much larger than WebP/JPEG.

The quality field (1-100) maps to:

  • WebP: q=quality
  • JPEG: q=quality
  • PNG: ignored (always lossless)

What input formats are accepted

inputFormats: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"]

The upload picker filters on these extensions. Browser support for processing (via createImageBitmap):

  • ✓ JPG, PNG, WebP, GIF — universal
  • ⚠ AVIF — Chrome 85+, Firefox 113+, Safari 16.4+
  • ⚠ SVG — passes through unchanged (rasterising via canvas would be lossy)

For SVG specifically: the pipeline detects the input is SVG and stores the original rather than rasterising. Templates can use the SVG variant directly.

The original is discarded

After all variants are generated, the user's original file is NOT stored. Storage cost would double; very few use cases actually need the original.

Implications:

  • Switching themes that need a larger format than was generated: can't regenerate from the original. The pickFormat fallback chain (requested → default → largest available) handles it gracefully but you might end up with the wrong aspect ratio.
  • Re-cropping: not supported in the admin. Re-upload to get a fresh crop.

If you need to keep originals, store them outside the CMS (Dropbox, NAS) and re-upload as needed.

Picking variants in templates

import { pickFormat, pickMediaUrl } from "@flexweg/cms-runtime";

// Specific variant with fallback chain:
<img src={pickFormat(view, "large")} />

// Or any reasonable URL (legacy-friendly):
<img src={pickMediaUrl(view)} />

// Responsive:
<img
srcSet={`
${pickFormat(view, "small")} 480w,
${pickFormat(view, "medium")} 800w,
${pickFormat(view, "large")} 1600w
`}
sizes="(max-width: 600px) 480px, (max-width: 1024px) 800px, 1600px"
src={pickFormat(view, "medium")}
/>

pickFormat's fallback chain: requested → view.default → largest available → empty string. So a missing variant doesn't break — your <img> gets the closest available size.

Theme migration scenarios

Adding a new format to your theme

After deploying the new theme version, existing media doesn't have the new variant. The upload pipeline only runs at upload time.

Workarounds:

  1. Re-upload affected images via the media library
  2. Templates use pickFormat — fallback chain handles missing variants

Changing dimensions of an existing format

Old uploads have the OLD dimensions; new uploads have the NEW dimensions. There's no marker on the file itself indicating which version it is.

Workaround: rename the format. Old uploads keep the old name (large 1200×900), new uploads use the new name (large-2 1600×900). Templates pick large-2 first, fall back to large for legacy media.

Removing a format

Old uploads have the variant — file is still there, takes storage. The theme's manifest no longer references it. The pickFormat helper still finds it as a fallback.

Continue