Common Types

Media

The discriminated union returned anywhere a connector or component schema yields an image or video. Two variants, source-driven rendering through the Media component.

A product has images. A hero banner has a background image. A blog post has a featured image. A category card might have a video. The shape connectors and editors return for all of these is the same Media discriminated union: a type field plus a list of source URLs (one per variant the renderer can pick from). The storefront's <Media> component reads the sources, picks the right one for the current viewport, and hands the chosen URL to nuxt-image so the right provider can serve it.

import type { Media } from '@laioutr-core/core-types/common';

type Media = MediaImage | MediaVideo;

The variants exist because the renderer treats them differently. An image renders as a <picture> with responsive <source> tags. A video needs a <video> element with controls. Both wrap a list of sources that may include separate mobile and desktop variants, and each source carries the nuxt-image provider name that knows how to fetch and resize that particular URL.

Choosing a variant

VariantUse it forRenders as
imagePhotographs, illustrations, product shots, hero backgrounds<picture> with responsive <source> tags via nuxt-image
videoProduct demos, hero loops, autoplay backgrounds<video> with sources (no built-in player UI yet)

If you're a connector returning a product's main image, use MediaImage. If you're an editor picking from the media library, the schema field gives you both, optionally restricted via allowedTypes.

Variants

MediaImage

{
  type: 'image';
  sources: MediaSourceImage[];      // at least one
  alt?: string;
  placeholder?: MediaSourcePlaceholder;
}

Use this for any still asset. The sources array is the heart of the type: one entry per variant the renderer can choose from. A single static source covers the simple case. Multiple sources let you ship separate files for mobile and desktop, or different formats for different providers.

alt is the default alt text. The renderer uses it unless overridden via the <Media> component's alt prop.

placeholder shows a low-quality preview while the real image loads. See Placeholders.

MediaVideo

{
  type: 'video';
  sources: MediaSourceVideo[];      // at least one
  preview?: MediaImage;
  alt?: string;
}

Use this for moving assets. The preview field carries a still image to show as a poster while the video loads (or as a fallback in renderers that don't play video).

The Media UI Kit component does not currently render MediaVideo (it type-narrows to images). If you ship video content today, render it directly with a <video> element keyed off media.sources. A first-class video renderer is on the roadmap.

Sources

A Media object is mostly its sources: each one is a self-contained URL plus the metadata the renderer needs to pick and serve it.

MediaSourceImage

{
  provider: string;                 // nuxt-image provider name
  src: string;                      // URL or provider-specific id
  width?: number;
  height?: number;
  responsive?: 'static' | 'mobile' | 'desktop';
  focalPoint?: [number, number];    // [x, y] as fractions, e.g. [0.5, 0.5] for center
}

provider is the most load-bearing field. It tells nuxt-image which adapter to use to fetch and resize this URL. Common values are 'shopify' (Shopify CDN), 'cloudinary', 'directus', 'sanity', custom names registered by your app, or 'none' (a nuxt-image built-in) to pass the URL through unchanged.

width and height are the source's natural dimensions. Set them when the backend gives you that information. The renderer uses them to set the <img> width/height attributes (preventing layout shift) and to compute the native aspect ratio when aspectRatio={true} is passed.

responsive marks a source as mobile-only or desktop-only. See Responsive sources.

focalPoint lets the editor (or the connector) declare which part of the image should remain visible when the renderer crops the image. [0.5, 0.5] is center; [0.0, 0.0] is top-left.

MediaSourceVideo

{
  provider: string;
  src: string;
  width: number;                    // required for video
  height: number;                   // required for video
  length?: Duration;                // ISO 8601 duration string
  format?: string;                  // e.g. 'mp4', 'webm'
  responsive?: 'static' | 'mobile' | 'desktop';
}

Width and height are required because video players need them up front to reserve layout space. length is an ISO 8601 duration ('PT1M30S' for 90 seconds). format lets the renderer pick the best source for the browser when multiple formats are provided.

MediaSourcePlaceholder

type MediaSourcePlaceholder = ['solid', string] | ['thumbhash', string];

A two-element tuple. The first element picks the placeholder style; the second element carries the data. 'solid' takes a hex color. 'thumbhash' takes a thumbhash string (Shopify produces these out of the box). Both render under the real image until it loads.

Responsive sources

A Media value can ship separate sources for mobile and desktop. The renderer picks one per viewport based on the responsive field on each source:

  • 'static' (or omitted): the source works on all viewports. This is the default.
  • 'mobile': the renderer uses this source below the desktop breakpoint.
  • 'desktop': the renderer uses this source at and above the desktop breakpoint.

When both a mobile and a desktop source are present, the renderer emits a <picture> element with two <source> tags and a media query, letting the browser pick. When only one source exists, it's used everywhere.

const responsiveBanner: MediaImage = {
  type: 'image',
  alt: 'Summer collection 2026',
  sources: [
    { provider: 'shopify', src: 'https://cdn.shopify.com/.../mobile.jpg', width: 750, height: 1000, responsive: 'mobile' },
    { provider: 'shopify', src: 'https://cdn.shopify.com/.../desktop.jpg', width: 2400, height: 800, responsive: 'desktop' },
  ],
};

The desktop breakpoint defaults to md (the laioutr-ui theme breakpoint). Pass desktopBreakpoint to the <Media> component to change it.

Placeholders

Placeholders give you something to show on screen before the full image arrives, eliminating the visual jank of a blank box jumping into a photo.

{
  type: 'image',
  sources: [...],
  placeholder: ['solid', '#1a1a1a'],
}
{
  type: 'image',
  sources: [...],
  placeholder: ['thumbhash', 'wXj5SoJ4eIB3iIeHf...'],
}

'solid' is the right choice when the connector knows the dominant color of the image (Shopify exposes this; some DAMs do too). 'thumbhash' produces a tiny blurred preview that looks like the real image at very low resolution. Both are inline (no extra HTTP request).

Rendering Media

A Media value is just data. Turning it into pixels on the page is the job of the <Media> UI Kit component, which reads the sources, picks the right one for the viewport, and hands the URL to nuxt-image:

components/SomeBlock.vue
<script setup lang="ts">
import type { MediaImage } from '@laioutr-core/core-types/common';
const props = defineProps<{ image: MediaImage }>();
</script>

<template>
  <Media :media="props.image" sizes="100vw md:50vw" />
</template>

The component handles responsive source selection, focal-point cropping, placeholder display, lazy loading, and the imagesizes/imagesrcset link tags for above-the-fold preloading. See the <Media> component reference for the full prop list.

Laioutr's frontend-core registers nuxt-image with provider: 'none' at the module level, which disables the global default; each source's provider field is what selects an adapter at render time. The same name 'none' is also a valid per-source provider: it's nuxt-image's built-in pass-through that returns the URL unchanged. If you set a source's provider to a name nobody has registered, nuxt-image errors at provider-resolution time rather than silently falling back.

For connector authors

This is the section that prevents the most common bug in connector code: returning media the storefront can't render efficiently. The rule is short:

Map your backend's asset shape to a Media object with the right provider. Never return a bare URL string, never default everything to 'none', and never drop the dimensions when the backend gives them to you.

Wrong

// Shopify connector, product media handler
return {
  cover: product.featuredImage.url,                        // ❌ bare string
  gallery: product.images.map((img) => ({ url: img.url })), // ❌ ad-hoc shape
};

This breaks for three independent reasons:

  1. Bare strings aren't Media. The frontend doesn't know whether this is an image or a video, what its dimensions are, or which nuxt-image provider can serve it. Components that expect Media will type-error or misrender.
  2. No provider. The renderer can't ask Shopify's CDN to resize the image, so users download full-resolution originals on every viewport.
  3. No dimensions. The <img> tag ships without width/height, causing layout shift as each image arrives.
// Shopify connector, product media handler
import type { MediaImage } from '@laioutr-core/core-types/common';

const toMediaImage = (img: ShopifyImage): MediaImage => ({
  type: 'image',
  alt: img.altText ?? '',
  placeholder: img.thumbhash ? ['thumbhash', img.thumbhash] : undefined,
  sources: [
    {
      provider: 'shopify',
      src: img.url,
      width: img.width,
      height: img.height,
    },
  ],
});

return {
  cover: toMediaImage(product.featuredImage),
  gallery: product.images.map(toMediaImage),
};

The connector says "this is an image, served by Shopify's CDN, with these dimensions and this thumbhash for the LQIP." The frontend's nuxt-image setup decides how to resize, format, and lazy-load it. Multi-viewport rendering, placeholder behavior, and preloading all stay where they belong: in the storefront.

Picking the right provider

The provider field must match a nuxt-image provider that the storefront has configured (or that your app registers). Common cases:

If the asset comes from…Use providerNotes
Shopify Files / Storefront API'shopify'Built into the Shopify app; supports CDN resizing parameters
Cloudinary'cloudinary'Configure with your Cloudinary cloud name
A DAM with a custom URL schemea custom name you registerRegister a nuxt-image provider in your app
A plain CDN URL with no resize API'none'nuxt-image built-in pass-through; skips optimization but works

Picking 'none' everywhere "to keep things simple" gives up the bandwidth savings and format conversion that nuxt-image is there to do. Spend the time wiring up the right provider; it pays back on every page load.

Returning responsive sources

If your backend stores separate mobile and desktop assets (Hygraph and many DAMs let editors crop per breakpoint), return them both with the responsive field set:

{
  type: 'image',
  alt: hero.alt,
  sources: [
    { provider: 'hygraph', src: hero.mobile.url, width: hero.mobile.width, height: hero.mobile.height, responsive: 'mobile' },
    { provider: 'hygraph', src: hero.desktop.url, width: hero.desktop.width, height: hero.desktop.height, responsive: 'desktop' },
  ],
}

If your backend only stores one asset, return one source with responsive: 'static' (or omit responsive). The renderer handles both cases.

  • <Media> component: the UI Kit component that renders Media values.
  • Media Library: how editors pick Media values from connected backends in Cockpit, and how to add a media library provider for your asset system.
  • media schema field: the editor-facing media picker that produces a Media value.
  • nuxt-image documentation: provider configuration, sizes syntax, and image optimization concepts.
Copyright © 2026 Laioutr GmbH