> ## Documentation Index
> Fetch the complete documentation index at: https://spreecommerce.org/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Media

> Product media (images, videos), named variants, focal points, and how media is served via the Store API

export const Since = ({version, from}) => {
  const knownPrevious = {
    '5.0': '4.10',
    '6.0': '5.4'
  };
  const previous = (from ?? knownPrevious[version]) ?? (() => {
    const [major, minor] = version.split('.').map(Number);
    if (Number.isNaN(major) || Number.isNaN(minor) || minor < 1) {
      throw new Error(`<Since version="${version}" />: cannot derive previous version automatically. ` + `Pass an explicit "from" prop, e.g. <Since version="${version}" from="X.Y" />.`);
    }
    return `${major}.${minor - 1}`;
  })();
  return <Tooltip tip={`Available since Spree ${version}+.`} cta="Upgrade instructions" href={`/developer/upgrades/${previous}-to-${version}`}>
      <Badge icon="lock">Spree {version}+</Badge>
    </Tooltip>;
};

## Overview

Spree handles uploads, processing, and delivery for product media. Images are automatically converted to WebP format and preprocessed into multiple sizes for optimal performance.

## Product Media

A media record carries:

* **Position** for ordering within the gallery
* **Media type** — `image`, `video`, or `external_video` (defaults to `image`)
* **Alt text** for accessibility and SEO
* **Focal point** coordinates for smart cropping
* **Preprocessed named variants** for fast delivery
* **`variant_ids`** — which product variants the media represents. An empty array means it represents the product as a whole.

### Product-level Gallery <Since version="5.5" />

In Spree 5.5 the **product** is the default owner of media. Before 5.5, every image was pinned to a specific variant (usually the master), and sharing the same image across variants meant re-uploading the file. From 5.5 onward, an image lives on the product, and any subset of variants can reference it through `variant_ids` — without duplicating the underlying file.

#### Uploading a product-level image

<CodeGroup>
  ```typescript Admin SDK theme={"theme":"night-owl"}
  import { createAdminClient } from '@spree/admin-sdk'

  const client = createAdminClient({ baseUrl, secretKey })

  // `signed_id` comes from a direct upload; see the Active Storage docs for
  // generating one client-side.
  const media = await client.products.media.create('prod_86Rf07xd4z', {
    signed_id: signedBlobId,
    alt: 'Front view',
    position: 1,
  })
  ```

  ```bash cURL theme={"theme":"night-owl"}
  curl -X POST 'https://api.mystore.com/api/v3/admin/products/prod_86Rf07xd4z/media' \
    -H 'X-Spree-API-Key: sk_xxx' \
    -H 'Content-Type: application/json' \
    -d '{
      "signed_id": "<signed-blob-id>",
      "alt": "Front view",
      "position": 1
    }'
  ```
</CodeGroup>

#### Sharing a single image across variants

Pass a `variant_ids` array on the same media endpoint to link/unlink variants. The server replaces the asset's link set on every call — empty array clears all links, omitting the field leaves them untouched.

<CodeGroup>
  ```typescript Admin SDK theme={"theme":"night-owl"}
  await client.products.media.update('prod_86Rf07xd4z', 'media_k5nR8xLq', {
    variant_ids: ['variant_redM', 'variant_redL'],
  })
  ```

  ```bash cURL theme={"theme":"night-owl"}
  curl -X PATCH 'https://api.mystore.com/api/v3/admin/products/prod_86Rf07xd4z/media/media_k5nR8xLq' \
    -H 'X-Spree-API-Key: sk_xxx' \
    -H 'Content-Type: application/json' \
    -d '{ "variant_ids": ["variant_redM", "variant_redL"] }'
  ```
</CodeGroup>

Variants belonging to a different product are silently dropped — the API rejects cross-product tampering at the model layer. Reordering happens once on the product gallery; every linked variant inherits the new order.

#### Storefront gallery resolution

The Store API's `media` field on a product returns its gallery — product-level media when present, falling back to legacy variant-pinned images during the transition. On a variant, `media` returns the assets linked to that variant via `variant_ids`, falling back to direct variant uploads.

This dual rendering means existing storefronts keep working during the upgrade; new uploads attach to the product, and you opt into a [one-shot migration](/developer/upgrades/5.4-to-5.5) to re-home legacy variant-pinned data when convenient.

### Named Variant Sizes <Since version="5.3" />

When an image is uploaded, Spree automatically generates optimized versions in the background:

| Name     | Dimensions | Use Case                       |
| -------- | ---------- | ------------------------------ |
| `mini`   | 128x128    | Thumbnails, cart items         |
| `small`  | 256x256    | Product listings, galleries    |
| `medium` | 400x400    | Product cards, category pages  |
| `large`  | 720x720    | Product detail pages           |
| `xlarge` | 2000x2000  | Zoom, high-resolution displays |

All variants are cropped to fill the exact dimensions and converted to WebP format.

## Store API

### Thumbnails (Always Available)

Every product response includes a `thumbnail_url` field — ready to use without any expands. Similarly, each variant includes a `thumbnail` URL and a `media_count` counter.

<CodeGroup>
  ```typescript SDK theme={"theme":"night-owl"}
  // List products — thumbnail_url is always included
  const { data: products } = await client.products.list({ limit: 12 })

  products.forEach(product => {
    product.thumbnail_url // "https://cdn.../tote-front.webp" — no expand needed
  })
  ```

  ```bash cURL theme={"theme":"night-owl"}
  # thumbnail_url is always in the response — no ?expand needed
  curl 'https://api.mystore.com/api/v3/store/products?limit=12' \
    -H 'Authorization: Bearer pk_xxx'
  ```
</CodeGroup>

<Warning>
  Avoid using `?expand=media` on listing pages. This loads **all** media for every product in the response. Use `thumbnail_url` instead and only expand full media on product detail pages.
</Warning>

### Full Media (On Demand)

On the product detail page, expand `media` and `variants` to get the full set of media with all named variant URLs:

<CodeGroup>
  ```typescript SDK theme={"theme":"night-owl"}
  const product = await client.products.get('spree-tote', {
    expand: ['media', 'variants'],
  })

  // Product media gallery
  product.media // [{ id, media_type, product_id, variant_ids, original_url, mini_url, ..., alt, position }, ...]

  // Each variant has its own thumbnail and media_count
  product.variants?.forEach(variant => {
    variant.thumbnail    // "https://cdn.../tote-red.webp" — always available
    variant.media_count  // 3 — quick check without loading media
    variant.media        // full media array (only with ?expand=media)
  })
  ```

  ```bash cURL theme={"theme":"night-owl"}
  curl 'https://api.mystore.com/api/v3/store/products/spree-tote?expand=media,variants' \
    -H 'Authorization: Bearer pk_xxx'
  ```
</CodeGroup>

**Response (media object):**

```json theme={"theme":"night-owl"}
{
  "id": "media_k5nR8xLq",
  "media_type": "image",
  "product_id": "prod_86Rf07xd4z",
  "variant_ids": ["variant_m3Rp9wXz"],
  "position": 1,
  "alt": "Front view",
  "focal_point_x": null,
  "focal_point_y": null,
  "external_video_url": null,
  "original_url": "https://cdn.example.com/images/original.jpg",
  "mini_url": "https://cdn.example.com/images/mini.webp",
  "small_url": "https://cdn.example.com/images/small.webp",
  "medium_url": "https://cdn.example.com/images/medium.webp",
  "large_url": "https://cdn.example.com/images/large.webp",
  "xlarge_url": "https://cdn.example.com/images/xlarge.webp"
}
```

### Media Fields Summary

| Field           | Available on     | Always Returned | Description                                 |
| --------------- | ---------------- | :-------------: | ------------------------------------------- |
| `thumbnail_url` | Product          |       Yes       | URL to the product's first media            |
| `thumbnail`     | Variant          |       Yes       | URL to the variant's first media            |
| `media_count`   | Variant          |       Yes       | Number of media items (counter cache)       |
| `media`         | Product, Variant |        No       | Full media array (requires `?expand=media`) |

### Media Object Fields

| Field                       | Type           | Description                                                                                                     |
| --------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------- |
| `id`                        | string         | Prefixed ID (`media_xxx`)                                                                                       |
| `media_type`                | string         | `image`, `video`, or `external_video`                                                                           |
| `product_id`                | string \| null | Owning product prefixed ID                                                                                      |
| `variant_ids`               | string\[]      | Associated variant prefixed IDs (empty = product-level)                                                         |
| `position`                  | number         | Sort order                                                                                                      |
| `alt`                       | string \| null | Alt text                                                                                                        |
| `focal_point_x`             | number \| null | Horizontal focal point (0.0–1.0)                                                                                |
| `focal_point_y`             | number \| null | Vertical focal point (0.0–1.0)                                                                                  |
| `external_video_url`        | string \| null | External video URL (YouTube/Vimeo)                                                                              |
| `original_url`              | string \| null | Full-size image URL (inline disposition)                                                                        |
| `mini_url` ... `xlarge_url` | string \| null | Named variant URLs                                                                                              |
| `download_url`              | string \| null | Same blob as `original_url` but with `Content-Disposition: attachment`. Admin API only. <Since version="5.5" /> |

## Image Processing

Spree uses [libvips](https://www.libvips.org/) for image processing. Images are automatically:

* Converted to **WebP format** for optimal file size
* **Preprocessed on upload** into all named variant sizes
* **Cached** for subsequent requests

## Storage

Spree supports two storage service types:

| Service         | Purpose                             | Examples              |
| --------------- | ----------------------------------- | --------------------- |
| Public storage  | Product images, logos, taxon images | S3 public bucket, CDN |
| Private storage | CSV exports, digital downloads      | S3 private bucket     |

<Info>
  For production deployments, use cloud storage (S3, GCS, Azure) instead of local disk storage. See [Asset Deployment](/developer/deployment/assets) for configuration details.
</Info>

## Best Practices

* **Use `thumbnail_url`** on listing pages — avoid loading full media via expand
* **Always provide alt text** for accessibility and SEO
* **Use named variant sizes** (`mini`, `small`, `medium`, `large`, `xlarge`) for optimal performance
* **Use a CDN** in production for faster delivery

## Related Documentation

* [Products](/developer/core-concepts/products) — Product catalog and media
* [Deployment — Assets](/developer/deployment/assets) — Storage and CDN configuration
