Wocart

Images

An image field does not store a URL. It stores the image's media key in the store's own media:

JSONCode
{ "image": { "key": "products/3f2a9c.webp", "alt": { "ar": "فستان أزرق", "fr": "Robe bleue" }, "width": 1200, "height": 1600, "focal": { "x": 0.5, "y": 0.3 } } }
ValueMeaning
keyThe image's media key: a path the platform chose under one of the store's folders (products/, landing/, reviews/); no leading slash, no ..
altAlt text, a string or one per language; it reaches the component in the rendered language
width, heightOptional intrinsic size in whole pixels, so the layout can reserve space
focalOptional focal point, { x, y }, each from 0 to 1

The URL is derived at render from the store's media delivery, so moving the store's images to another host never rewrites a document. An image field has no default: a theme cannot know a store's media, so validateThemeDefinition refuses a default on one, and the component renders its own fallback when nothing is picked.

The SDK never uploads. The platform places the file in the store's media and saves the key; the theme only reads that key.

The store's media delivery

config.media is the store's MediaDelivery, { kind, origin }: where every key is served from (<origin>/<key>) and how that origin resizes.

kindResizing
imagekitImageKit's ?tr= transformations
cloudflareCloudflare's /cdn-cgi/image/…, with onerror=redirect, so a failed resize shows the original
directNone: images are served as stored

A theme never names a host and needs no environment variable for images: the same build serves stores on all three kinds.

Resolve a key

A component receives the image with alt already in the rendered language. pictureOf(image, delivery) from @wocart/storefront turns it into what an <img> needs:

Code
--- import { mediaImageUrl, pictureOf } from "@wocart/storefront"; import type { InferProps } from "@wocart/theme"; import type { banner } from "./banner.def"; const { image } = Astro.props as InferProps<typeof banner>; const { media } = Astro.locals.store.config; const picture = pictureOf(image, media); const src = picture ? (mediaImageUrl(picture.src, { width: 1200, fit: "scale-down", format: "auto" }, media) ?? picture.src) : null; --- {picture && src ? <img src={src} alt={picture.alt} width={picture.width} height={picture.height} style={picture.position ? `object-position: ${picture.position}` : undefined} loading="lazy" /> : <div class="banner-fallback" />}
  • pictureOf answers null when nothing is picked, the key is not a media key, or there is no delivery. Render the fallback then, never a broken <img>.
  • picture.alt is the resolved alt text, "" when the merchant wrote none. Do not rebuild it from the language map.
  • picture.position is the focal point as object-position (50% 30%), for an image cropped with object-fit: cover.
  • mediaImageUrl answers null when the delivery cannot resize (a direct delivery): keep picture.src then. The sizes, formats and srcset helpers are on Money, images, WhatsApp and Meta events.

The starter binds these helpers once, in src/lib/images.ts, and draws every photo through src/components/ui/Photo.astro, which asks for a srcset of widths so a phone downloads a phone-sized file.

Astro's <Image> on any media host

@wocart/theme/image-service adapts Astro's image pipeline to the store's media delivery. Every <Image> and getImage() of a store image is resized by the store's own resizer (ImageKit or Cloudflare), so a storefront on Cloudflare and one on Vercel get the same URLs and no second image bill. Any other image (another host, a file imported into the theme) is served as it is, and so is every image of a direct delivery.

Astro asks the service for a URL without the request, so the service reads the delivery through a function you give it, on every image, never at build:

TypeScriptCode
// src/image-service.ts import type { MediaDelivery } from "@wocart/storefront"; import { mediaImageService } from "@wocart/theme/image-service"; let storeMedia: MediaDelivery | null = null; export function keepStoreMedia(media: MediaDelivery): void { storeMedia = media; } export default mediaImageService(() => storeMedia);

Call keepStoreMedia(config.media) where the request's store is built, for every config read from the server (not for the unavailable store, whose id is ""). Before any delivery is known, every image passes through unchanged. The starter does not use this service: it keeps Astro's passthrough service and draws store images with the helpers above.

In astro.config.mjs, the entrypoint replaces the quick start's passthrough service, on both hosts:

JavascriptCode
// astro.config.mjs image: { service: { entrypoint: "./src/image-service.ts" } },
AdapterSetting
@astrojs/cloudflareimageService: "custom" instead of "passthrough"
@astrojs/vercelLeave imageService off; the entrypoint is used

Never fall back to Astro's default sharp service: it puts native binaries for the build machine into the Vercel output, which cannot be deployed.

What the service does

mediaImageParams(options) maps Astro's transform to the resize parameters mediaImageUrl takes:

Astro optionParameter
width, heightwidth, height
formatformat
quality (a number)quality
fitcover, contain, scale-down, crop, pad and squeeze pass as they are; fill becomes squeeze

mediaImageService(delivery) returns an Astro service whose getURL resolves through mediaImageUrl and falls back to the original URL. It extends Astro's base service, so <Image> and getImage() keep their usual behavior.

focal is not applied by the service: it is a hint for the theme's own CSS or markup. width and height are the intrinsic values; a transform can still request another size.

In a section

Code
--- import { Image } from "astro:assets"; import { pictureOf } from "@wocart/storefront"; import type { InferProps } from "@wocart/theme"; import type { hero } from "./hero.def"; const { image } = Astro.props as InferProps<typeof hero>; const picture = pictureOf(image, Astro.locals.store.config.media); --- {picture && ( <Image src={picture.src} alt={picture.alt} width={picture.width ?? 1200} height={picture.height ?? 800} widths={[480, 768, 1200]} sizes="(min-width: 768px) 50vw, 100vw" /> )}

A remote src goes through the configured service. An imported local image is handled by Astro's base service as usual.

Rules

  • Never store a URL in an image field. Store the key; resolve at render.
  • Never build a media URL by string concatenation, and never name a media host. Use pictureOf, mediaUrl, mediaImageUrl or the service with config.media.
  • Never omit alt. An empty alt is valid only for a decorative image.
  • Do not assume a format. A store may hold WebP, JPEG or another supported type; ask for format: "auto" and let the host choose.
  • Do not read the field as a string. It is an image reference, { key, alt?, width?, height?, focal? }.