Skip to content
Client
Server

Play song previews ​

Every song and music video in Apple's catalog comes with a short audio clip, its preview. In this guide you play those clips in a page: a queue of tracks, play, pause, next and previous, and a display that follows along.

Before you start, you need:

No listener has to sign in. The player asks Apple for nothing: it plays the clips of tracks you already fetched. It runs in a page only, because it needs an audio element. The API hands over no audio but previews, so this SDK has no way to play a whole song. MusicKit on the Web plays whole songs for a listener with an Apple Music subscription.

In the code below, playButton, pauseButton, nextButton and previousButton are buttons in your page, title is an element that shows the track's name, and bar is a <progress> element. say and showError stand for your own code: say(message) shows the listener a message, and showError(error) shows an error.

In the code below, say and showError stand for your own code: say(message) shows the listener a message, and showError(error) shows an error.

1. Get the tracks from the catalog ​

In your page, fetch songs or music videos with the catalog. Here they are an album's tracks:

ts
import {
  createBrowserClient,
  previewPlayer,
  type tMusicVideo,
  type tSong,
} from "@open-music-sdk/browser";

const music = createBrowserClient({
  tokenUrl: "/api/music/token", // your developer-token route
  storefront: "us",
});

const album = await music.catalog.getAlbum("1613600183", {
  include: ["tracks"],
});
const tracks = album.relationships?.tracks?.data ?? [];

In a component inside your MusicProvider and TanStack Query's QueryClientProvider, fetch songs or music videos with a catalog query. Here they are an album's tracks:

tsx
import { useMusicQueries } from "@open-music-sdk/react";
import { useQuery } from "@tanstack/react-query";

export function AlbumPreviews({ id }: { id: string }) {
  const { data: album } = useQuery(
    useMusicQueries().catalog.getAlbum(id, { include: ["tracks"] }),
  );
  const tracks = album?.relationships?.tracks?.data;
  return tracks ? <Previews tracks={tracks} /> : null;
}

A query's list keeps its identity between renders, which the player in step 2, Previews, needs.

In Next.js, AlbumPreviews and Previews are client components, whose files start with "use client". A Next app can fetch the tracks in a server component instead, with a client from createServerClient, and hand them to Previews: they're plain data.

Any catalog function that hands over songs or music videos works: a search, a chart, or resolve from Import a playlist. A song in a listener's library has no preview, and neither has a song fetched without its attributes.

2. Load them into a player ​

In your page, make a player and load the tracks:

ts
type tTrack = tSong | tMusicVideo;

const player = previewPlayer<tTrack>();
const { tracks: queue, skipped } = player.load(tracks);

previewPlayer() makes its own audio element with new Audio() and listens to it. The type parameter is the kind of track you feed it, and is what player.state.track is typed as.

In a component, usePreviewPlayer gives you the player's state and its functions. Load the tracks from an effect:

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

type tTrack = tSong | tMusicVideo;

export function Previews({ tracks }: { tracks: readonly tTrack[] }) {
  const { state, load, play, pause, next } = usePreviewPlayer<tTrack>();

  useEffect(() => {
    load(tracks);
  }, [tracks, load]);

  // … draw the player, as in step 4
}

The type parameter is the kind of track you feed it, and is what state.track is typed as.

  • The hook makes the player, with its own audio element, when the component mounts, and disposes of it when the component unmounts. It needs no MusicProvider.
  • load, play, pause, next, previous, seek and setVolume keep their identity for as long as the component lives, so you can list them in an effect's dependencies.
  • Call them from a click or from an effect of the component that called the hook. One called before the player exists, which only a child's effect can do, throws a TypeError.
  • The effect above loads the list again whenever tracks changes, which stops what is playing. Hand it a list that keeps its identity between renders, such as one from a query.

The hook reads its options on the first mount only.

load replaces the queue with the tracks that have a clip, in the order you gave them, and plays nothing. Each entry of skipped is a track it left out, with its index in your list and a reason: "no-preview" for a track with no clip, "host" for a clip on a host you don't allow (see step 6). See tSkippedPreview.

A track without a clip is never an error, so you can draw it with its play button turned off.

Loading a new queue stops what was playing, takes the old clip off the element, and leaves no track current.

3. Start sound from a click ​

Browsers don't let a page start sound on its own: the first sound has to follow something the listener did. So call play, next or previous from a click or a key press.

In your page:

ts
playButton.onclick = () => void player.play().catch(showError);
pauseButton.onclick = () => player.pause();
nextButton.onclick = () => void player.next().catch(showError);
previousButton.onclick = () => void player.previous().catch(showError);

In the component, from the buttons' onClick:

tsx
<button onClick={() => void play().catch(showError)}>Play</button>
<button onClick={() => pause()}>Pause</button>
<button onClick={() => void next().catch(showError)}>Next</button>
  • play() carries on with the current clip, or starts the first track. play(2) plays the third track from its start.
  • next() and previous() play the neighbouring track from its start. At an end of the queue they do nothing.
  • When a clip ends, the next track starts. After the last one the status is ended, and play() starts again from the first.
  • seek(seconds) moves within the current clip, and setVolume(volume) takes a number from 0 to 1.

play, next and previous settle as the element's own play() does. They reject with the browser's own error:

  • NotAllowedError when the browser won't start sound, because no click came before it. The clip stays paused.
  • AbortError when a later call overtakes this one, as when next is clicked twice quickly. The state is the later call's, so catch the error and carry on.

Not yet run against the live API

Don't count on the next clip starting without a second click until you've tried it in the browsers you support: the player hasn't played a clip from the live API in a browser yet. See Differences from Apple's documentation for what it takes on trust.

A list with a Play button on each row plays the whole list, from the row that was clicked. The track's place in the queue load gives back can differ from its place in your list, since tracks with no clip are left out.

The vanilla sample does it this way, with the player and tTrack from step 2:

ts
/**
 * Plays a list of tracks, from one of them. Called from a click: a browser
 * starts sound only after one.
 */
export function playFrom(tracks: readonly tTrack[], track: tTrack): void {
  // Tracks with no preview are left out of the queue, so the track's place in
  // the queue can differ from its place in the list.
  const { tracks: queue, skipped } = player.load(tracks);
  if (skipped.length > 0) {
    const count = String(skipped.length);
    say(`${count} of these tracks have no preview, and are left out.`);
  }
  const place = queue.indexOf(track);
  // The track clicked is one of those with no preview.
  if (place === -1) return;
  player.play(place).catch(showError);
}

The React and Next samples do it this way, inside a component like Previews in step 2, with its tTrack, load and play, and React's useCallback:

tsx
/**
 * Plays a list of tracks, from one of them. Called from a click: a browser
 * starts sound only after one.
 */
const playFrom = useCallback(
  (tracks: readonly tTrack[], track: tTrack) => {
    // Tracks with no preview are left out of the queue, so the track's place
    // in the queue can differ from its place in the list.
    const { tracks: queue, skipped } = load(tracks);
    if (skipped.length > 0) {
      const count = String(skipped.length);
      say(`${count} of these tracks have no preview, and are left out.`);
    }
    const place = queue.indexOf(track);
    // The track clicked is one of those with no preview.
    if (place === -1) return;
    play(place).catch(showError);
  },
  [load, play],
);

4. Draw the player's state ​

In your page, subscribe to the player and draw from player.state:

ts
player.subscribe(() => {
  const { status, track, position, duration } = player.state;
  title.textContent = track?.attributes?.name ?? "";
  bar.value = duration === 0 ? 0 : position / duration;
  playButton.hidden = status === "playing";
});

subscribe calls your listener after each change and returns the function that stops it. While a clip plays, position changes several times a second.

A listener may call the player back, for example to pause or load another queue, and what it does stands. A listener that throws stops neither the player nor the other listeners. The first error is then thrown to whoever called the player, or out through the element's event.

In the component, draw from state. The component renders again after each change:

tsx
return (
  <div>
    <p>{state.track?.attributes?.name}</p>
    {state.status === "playing" ?
      <button onClick={() => pause()}>Pause</button>
    : <button onClick={() => void play().catch(showError)}>Play</button>}
    <button onClick={() => void next().catch(showError)}>Next</button>
  </div>
);

While a clip plays, position changes several times a second. On the server, and before the component mounts, state is one idle snapshot, so a server render and a hydration agree.

The state is a frozen snapshot, and stays the same object until something changes. It holds the status, the queue as tracks, the current index and track, the clip's position and duration, and the volume. See tPreviewPlayerState.

status is one of idle, loading, playing, paused, ended and error, which tPreviewStatus explains. After an error, next() moves on, and play() asks for the same clip again.

5. Play through your own audio element ​

To keep an <audio> element's own controls on the page, hand the element to the player.

In your page:

ts
const audio = document.querySelector<HTMLAudioElement>("#preview")!;
const player = previewPlayer<tTrack>({ audio });

Pass the element or a ref to it. The hook reads the ref when the component mounts, and a ref with nothing in it then is a TypeError. Here tTrack is step 2's:

tsx
import { usePreviewPlayer } from "@open-music-sdk/react";
import { useRef } from "react";

export function Player() {
  const audio = useRef<HTMLAudioElement>(null);
  const { state, load, play } = usePreviewPlayer<tTrack>({ audio });
  // … load and play as in steps 2 and 3
  return <audio ref={audio} controls />;
}

The player never adds the element to the page or takes it out. It sets the element's src, currentTime and volume, calls play(), pause(), load() and removeAttribute("src"), and listens to eight of its events. Whatever else pauses the element, such as its own controls, shows as paused in the state, and a volume set on the element shows as the state's volume.

When you're done with the player, call player.dispose(). It pauses, takes the clip and the player's listeners off the element, and forgets the queue and your subscribers. The element is yours again. After that, play, next and previous reject with a TypeError, load, pause, seek, setVolume and subscribe throw one, and dispose() again does nothing. Where the runtime has Symbol.asyncDispose, await using player = previewPlayer() disposes it for you; in a browser without it, call dispose().

The hook disposes of the player when the component unmounts. It pauses, takes the clip and the player's listeners off the element, and forgets the queue. The element is yours again.

6. Check where clips point ​

A clip's URL comes from the track you load. A track that came from Apple's API a moment ago can be played as it is.

A stored track can point anywhere

A track from a cache, a database that others can write to, or a shared link can hold any URL, and the audio element will request it. See URLs in Apple's answers.

For tracks like those, pass hosts when you make the player:

ts
const player = previewPlayer<tTrack>({ hosts: ["itunes.apple.com"] });
tsx
const { state, load, play } = usePreviewPlayer<tTrack>({
  hosts: ["itunes.apple.com"],
});

hosts takes up to 16 bare host names, in lowercase. With it, a track whose clip isn't https on one of those hosts or a subdomain of one, with no port and no credentials, is skipped with the reason "host". Without it, where a clip points isn't checked at all.

Apple doesn't document which host serves the clips. Against the live API on 18 October 2026, the clips of 50 chart songs were all on audio-ssl.itunes.apple.com, which itunes.apple.com allows: see Differences from Apple's documentation. Look at what your own answers hold before you set hosts.

The player checks audio and hosts when it's made, so a mistake in either is a TypeError then, not on the first click. Changing your options object afterwards changes nothing.

When something goes wrong ​

  • A mistake in how you call a function is a TypeError that names the function and the argument, such as previewPlayer.seek: seconds must be …. load, seek, setVolume and previewPlayer's subscribe throw it, and play, next and previous reject with it. No such error repeats the value you passed.
  • What the element refuses reaches you as the browser made it, such as the NotAllowedError in step 3.
  • No error here is an AppleMusicError, because the player asks Apple for nothing.

Next steps ​

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