Skip to content
Client
Server

Show artwork ​

Apple doesn't send an image URL for artwork. It sends an artwork template: a URL such as https://…/{w}x{h}bb.jpg, with the pixel size left for you to fill in. In this guide you turn one into an <img> that is sharp on a dense screen and laid out at the right size.

Before you start, you need:

  • a resource with its attributes, such as a song or an album you fetched with a client.

The artwork functions come from @open-music-sdk/browser, @open-music-sdk/react and @open-music-sdk/server alike. They run anywhere: in a page, on a server, or wherever you write HTML. They touch neither the page nor the network.

In the code below, song stands for a song you fetched with a client.

1. Check that the resource has artwork ​

Anywhere, read the artwork from the resource's attributes:

ts
const artwork = song.attributes?.artwork;
// { url: "https://…/{w}x{h}bb.jpg", width: 3000, height: 3000,
//   bgColor: "…", … }

Some resources have no artwork, and a resource can come without its attributes. The functions take an artwork, not the lack of one, so check before you call them. The one-line examples below leave the check out.

width and height are the largest the image comes, in pixels. The artwork also carries colours you can use while the image loads: bgColor and textColor1 to textColor4, each a hex colour without the #.

2. Fill in an <img> with artworkImage ​

In your page, where img is an <img> element, give artworkImage the artwork and the width you show the image at, in CSS pixels:

ts
import { artworkImage } from "@open-music-sdk/browser";

if (artwork) Object.assign(img, artworkImage(artwork, 300));
// img.src     https://…/300x300bb.jpg
// img.srcset  https://…/300x300bb.jpg 1x, https://…/600x600bb.jpg 2x,
//             https://…/900x900bb.jpg 3x
// img.width   300
// img.height  300

In a component, give artworkImage the artwork and the width you show the image at, in CSS pixels. React spells the attribute srcSet:

tsx
import { artworkImage, type tSong } from "@open-music-sdk/react";

export function Cover({ song }: { song: tSong }) {
  const artwork = song.attributes?.artwork;
  if (!artwork) return null;
  const { srcset, ...image } = artworkImage(artwork, 300);
  return <img {...image} srcSet={srcset} alt={song.attributes?.name} />;
}

artworkImage works out src, srcset, width and height together, and they have to agree. A browser takes src as the 1x image wherever the srcset doesn't offer one, and lays out an image that has no width and height by the density of whichever candidate it chose. Use it unless you need only one of the parts.

The vanilla sample draws its 240-pixel covers with one function, where h(tag, props) is a helper of its own that makes an element with those properties set. A resource with no artwork gets an empty box of the same size, so a page laid out around covers keeps its shape:

ts
import { artworkImage, type tArtwork } from "@open-music-sdk/browser";
import { h } from "./dom";

/**
 * An `<img>` for an artwork shown `size` CSS pixels wide, with a `srcset` for
 * screens of higher density. A resource with no artwork gets an empty box of
 * the same size.
 */
export function cover(
  artwork: tArtwork | undefined,
  size: number,
): HTMLImageElement {
  if (artwork === undefined) {
    return h("img", { alt: "", width: size, height: size });
  }
  return h("img", { alt: "", ...artworkImage(artwork, size) });
}

The React sample draws its 240-pixel covers with one component. A resource with no artwork gets an empty box of the same size, so a page laid out around covers keeps its shape:

tsx
import { artworkImage, type tArtwork } from "@open-music-sdk/react";

interface tCoverProps {
  readonly artwork: tArtwork | undefined;
  /** How wide the cover is shown, in CSS pixels. */
  readonly size: number;
  /** Loaded only once it nears the screen. */
  readonly lazy?: boolean;
}

/**
 * An `<img>` for an artwork shown `size` CSS pixels wide, with a `srcSet` for
 * screens of higher density. A resource with no artwork gets an empty box of
 * the same size.
 */
export function Cover({ artwork, size, lazy = false }: tCoverProps) {
  const loading = lazy ? "lazy" : undefined;
  if (artwork === undefined) {
    return <img alt="" width={size} height={size} loading={loading} />;
  }
  // React spells the attribute srcSet.
  const { srcset, ...image } = artworkImage(artwork, size);
  return <img alt="" {...image} srcSet={srcset} loading={loading} />;
}

In a Next.js app, you can draw artwork with next/image instead, through a component you can take into your app: see With next/image.

How the size is chosen:

  • With no height option, the image keeps the artwork's own shape: a 1920 by 1080 still shown at 320 is asked for as 320x180. Artwork that doesn't say how large it comes is taken to be square.
  • No image is asked for larger than the artwork comes. A box that asks for more shrinks in its own shape. No side is asked for past 10,000 pixels, where Apple's image server stops.
  • width and height are the size the image is shown at, in whole CSS pixels, whatever resolution was fetched.

3. Get one URL with artworkUrl ​

Anywhere, artworkUrl gives the URL of the image at one width, the src alone:

ts
import { artworkUrl } from "@open-music-sdk/server";

const url = artworkUrl(artwork, 1200); // https://…/1200x1200bb.jpg

Use it where you need a single URL, such as an image for a link preview.

All three functions give the same URL, character for character. It is spelled the way a browser reads a src: white space dropped from its ends, tabs and line breaks dropped from anywhere, and a space inside written %20. None of this changes what is requested, so a check you make on the URL from artworkUrl holds for the srcset too.

4. Build a srcset alone with artworkSrcSet ​

In your page, where img is an <img> element, artworkSrcSet gives the srcset alone. Set the image's width and height yourself:

ts
import { artworkSrcSet } from "@open-music-sdk/browser";

if (artwork) {
  img.srcset = artworkSrcSet(artwork, 300, { densities: [1, 2] });
  img.width = 300;
  img.height = 300;
}

In a component, artworkSrcSet gives the srcset alone. Set the image's width and height yourself:

tsx
import { artworkSrcSet, type tSong } from "@open-music-sdk/react";

export function Cover({ song }: { song: tSong }) {
  const artwork = song.attributes?.artwork;
  if (!artwork) return null;
  const srcSet = artworkSrcSet(artwork, 300, { densities: [1, 2] });
  return (
    <img
      srcSet={srcSet}
      width={300}
      height={300}
      alt={song.attributes?.name}
    />
  );
}

Each candidate is the image at the width times a density, labelled with that density. densities takes up to 16 numbers, and defaults to [1, 2, 3].

Where the artwork doesn't come large enough for a density, the largest image it has is offered once, under the density it amounts to. A 500-pixel cover shown at 300 gives …/300x300bb.jpg 1x, …/500x500bb.jpg 1.67x, so the browser doesn't download the same image twice or mistake its size.

That is why you set width and height. A 200-pixel cover shown at 300 has the srcset…/200x200bb.jpg 0.667x. Without width and height, a browser lays that image out 200 pixels wide, not 300.

A URL with no size to fill in, such as a fixed URL for a listener's own playlist artwork, is one image of unknown size. It comes back alone, with no density.

5. Choose the format and the crop ​

Anywhere, format and crop write over what the template names. A template's file name says both: {w}x{h}bb.jpg is cut bb and encoded jpg.

ts
artworkUrl(artwork, 300, { format: "webp" }); // https://…/300x300bb.webp

format is one of "jpg", "jpeg", "png", "webp", "heic" or "heif", the formats Apple's image server was seen to convert to. For the one cover measured, WebP was a third of the JPEG's bytes.

crop is Apple's code for how the image is cut to the box, such as the bb most templates name. Apple doesn't document the codes, so any code of the right shape is accepted: see tArtworkOptions.

To fit artwork into a box of another shape, pass height as well:

ts
const image = artworkImage(artwork, 1200, { height: 300 });

What you get depends on the crop:

  • With bb, the whole image stands inside the box, never enlarged. A square cover in a 1200 by 300 slot is laid out 300 by 300. Don't set the box's own size on the <img>, or the image is stretched.
  • With sr, the image fills the box exactly, enlarged if it must be. A code the SDK doesn't know is taken to do the same.
  • With cc, it's a square as long as the longer of the two sides, enlarged if it must be.

Some templates leave the crop and the format open, as {c} and {f}. They're filled in the same way, with bb and jpg if you don't say. A URL that isn't a template of this form, such as a fixed URL for a listener's own playlist artwork, is returned as it is.

Apple documents {w}x{h} and nothing else. The rest is what its image server was seen to do: see Differences from Apple's documentation.

6. Check where the URL points ​

The functions fill in the URL the API gave, and don't look at where it points unless you ask. That is enough for artwork that came from Apple's API a moment ago.

Artwork from anywhere else can point anywhere

Artwork from a cache, a database that others can write to, a shared link or another service can hold any URL. Pass hosts for it. URLs in Apple's answers says what a bad URL can do.

Anywhere, pass the hosts the artwork may come from:

ts
artworkImage(artwork, 300, { hosts: ["mzstatic.com"] });

With hosts, a URL that isn't https on one of those hosts or a subdomain of one is a TypeError, and no URL is returned. Apple serves catalog artwork from is1-ssl.mzstatic.com and its numbered siblings. Artwork a listener gave a playlist of their own may come from elsewhere, so look at what your app receives before you settle the list.

What comes back is a URL, not markup. Assigned to an element, or given to React, it's escaped for you. Written into HTML by hand, escape it like any other attribute value, and don't put it inside a CSS url(…) as it is.

With next/image ​

In a Next.js app, next/image can take artworkUrl as its loader. It asks the loader for each width its srcset offers, and Apple's image server makes each one, so Next's own optimizer is never used and your Next config needs no remotePatterns for Apple. The component below, Artwork, does this. It comes from the Next sample and uses nothing else of it, so save it in your app as artwork.tsx, as it is:

tsx
"use client";
// Apple Music artwork through next/image. Nothing here is the sample's own, so
// the file can be taken into another app as it is.
//
// A client component, since a loader is a function, which a server component
// cannot hand to the page. A server component can still render it.
import {
  artworkUrl,
  type tArtwork,
  type tArtworkFormat,
} from "@open-music-sdk/react";
import Image, { type ImageLoader } from "next/image";
import type { ComponentProps } from "react";

/**
 * What next/image takes, but the props the artwork decides and those only
 * Next's own optimizer, which is never used, would read.
 */
type tImageProps = Omit<
  ComponentProps<typeof Image>,
  "src" | "loader" | "quality" | "unoptimized"
>;

export interface tArtworkProps extends tImageProps {
  /** A resource's `attributes.artwork`. With none, the box is left empty. */
  readonly artwork: tArtwork | undefined;
  /** The file format, such as `"webp"`. Default: the one the URL names. */
  readonly format?: tArtworkFormat | undefined;
  /**
   * The hosts the artwork may come from, such as `["mzstatic.com"]`. Pass it
   * for artwork that didn't come straight from Apple's API.
   */
  readonly hosts?: readonly string[] | undefined;
}

/**
 * An artwork laid out as next/image lays out any image: `width` CSS pixels
 * wide, or filling its parent with `fill`. Without a `height`, the artwork's
 * own shape decides it.
 *
 * next/image asks the loader for each width its `srcset` offers, and Apple's
 * image server makes each one, so Next's own optimizer is never used. The
 * artwork's own colour fills the box while the image loads.
 */
export function Artwork({
  artwork,
  format,
  hosts,
  ...props
}: tArtworkProps) {
  const width = props.width === undefined ? undefined : Number(props.width);
  const height = props.height ?? heightOf(artwork, width);
  // An image that fills its parent takes no height, so it is given none.
  const size = height === undefined ? {} : { height };
  if (artwork === undefined) {
    return <Image {...props} {...size} src={BLANK} />;
  }
  const options = { format, hosts };
  const loader: ImageLoader = ({ width }) =>
    artworkUrl(artwork, width, options);
  // A URL with no size to fill in, as the artwork of a listener's own
  // playlist has, is one image at every width: next/image is told so, rather
  // than handed a loader that cannot honour a width. It then uses `src` as it
  // is, so that comes from artworkUrl too, where `hosts` is checked. Any width
  // gives the same URL.
  const sized = SIZED.test(artwork.url);
  return (
    <Image
      {...props}
      {...size}
      loader={loader}
      unoptimized={!sized}
      src={sized ? artwork.url : artworkUrl(artwork, 1, options)}
      style={{ backgroundColor: colourOf(artwork), ...props.style }}
    />
  );
}

/**
 * Whether a URL has a size to fill in, as artworkUrl reads one: `{w}` or
 * `{h}`, with the braces percent-encoded or not.
 */
const SIZED = /\{[wh]\}|%7[Bb][wh]%7[Dd]/;

/** An image of nothing, for a box with no artwork to show. */
const BLANK = "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>";

/**
 * The height that keeps the artwork's own shape at `width`: `width` itself
 * where the artwork doesn't say how large it comes, and none with no width.
 */
function heightOf(
  artwork: tArtwork | undefined,
  width: number | undefined,
): number | undefined {
  if (width === undefined) return undefined;
  if (artwork === undefined || !(artwork.width > 0 && artwork.height > 0)) {
    return width;
  }
  return Math.round((width * artwork.height) / artwork.width);
}

/**
 * The artwork's average colour, as CSS. It is checked, as the server writes
 * a style into the page's HTML as it is: anything but six hex digits could
 * add declarations of its own.
 */
function colourOf({ bgColor }: tArtwork): string | undefined {
  if (bgColor === undefined) return undefined;
  return /^[0-9a-f]{6}$/i.test(bgColor) ? `#${bgColor}` : undefined;
}

In any component, server or client, give Artwork a resource's artwork and the width you show it at:

tsx
import type { tAlbum } from "@open-music-sdk/react";
import { Artwork } from "./artwork";

export function AlbumHeading({ album }: { album: tAlbum }) {
  const { name, artwork } = album.attributes ?? {};
  return (
    <header>
      <Artwork artwork={artwork} width={240} alt="" loading="eager" />
      <h1>{name}</h1>
    </header>
  );
}

The alt is empty because the heading beside the cover names the album. A cover on its own takes the name as its alt, as any image does.

Artwork takes:

  • artwork: a resource's attributes.artwork. With none, it draws an empty image in the same box, so a page laid out around covers keeps its shape. You don't need to check for artwork first.
  • width and height, as next/image takes them. Leave out height and the artwork's own shape decides it: a 1920 by 1080 still shown 320 wide is 180 high.
  • fill and sizes, as next/image takes them, for a box sized by CSS.
  • format, such as "webp", as in step 5.
  • hosts, the hosts the URL may point to, as in step 6.
  • Everything else next/image takes, such as className, loading and preload. The exceptions are src and loader, which Artwork sets, and quality and unoptimized, which only Next's own optimizer would use.

While the image loads, the box shows the artwork's bgColor.

For a cover that fills a grid cell, give the cell a position and a shape, and say how wide it is shown in sizes:

tsx
<div style={{ position: "relative", aspectRatio: 1 }}>
  <Artwork
    artwork={artwork}
    fill
    sizes="(max-width: 640px) 50vw, 240px"
    alt={name}
  />
</div>

How next/image and Artwork behave, as seen in Next 16 on 20 October 2026:

  • Next picks the widths from its own list, not from the width you show the image at. A cover shown at 240 asks for 256x256, labelled 1x, and 640x640, labelled 2x: for each density, the first of Next's imageSizes and deviceSizes at least that wide. artworkImage asks for exactly 240x240 and 480x480.
  • remotePatterns in your Next config doesn't apply under a loader of your own, so nothing checks where the URL points but hosts, as in step 6. Pass hosts to Artwork for artwork from anywhere but Apple's API.
  • A URL with no size to fill in, as the artwork of a listener's own playlist has, is the same image at every width. Next warns about a loader that can't honour a width, so Artwork passes unoptimized for such a URL. Next then calls no loader and uses src as it is, so Artwork makes that src with artworkUrl too, where hosts is checked.
  • The artwork's bgColor goes into a style that the server writes into the page's HTML without parsing it first. Artwork uses it only if it is six hex digits, as Apple sends it, so a colour from elsewhere can't add CSS declarations of its own.

When something goes wrong ​

An argument that isn't what the function takes is a TypeError, and so, with hosts, is an artwork whose URL isn't on one of them. The message says which argument was wrong, and for a number, what it was. No error shows the URL. Each function's reference, linked below, lists every case.

Next steps ​

MIT licensed. Not affiliated with, endorsed by, or sponsored by Apple Inc. Apple Music and MusicKit are trademarks of Apple Inc.