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:
- a route on your server that serves a developer token to your page. See Serve a developer token to a page.
- in a React or Next.js app,
MusicProviderand TanStack Query'sQueryClientProvideraround your components. See Set up your client.
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:
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:
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:
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:
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,seekandsetVolumekeep 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
trackschanges, 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:
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:
<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()andprevious()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, andplay()starts again from the first. seek(seconds)moves within the current clip, andsetVolume(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:
NotAllowedErrorwhen the browser won't start sound, because no click came before it. The clip stays paused.AbortErrorwhen a later call overtakes this one, as whennextis 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:
/**
* 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:
/**
* 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:
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:
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:
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:
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:
const player = previewPlayer<tTrack>({ hosts: ["itunes.apple.com"] });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
TypeErrorthat names the function and the argument, such aspreviewPlayer.seek: seconds must be ….load,seek,setVolumeandpreviewPlayer'ssubscribethrow it, andplay,nextandpreviousreject with it. No such error repeats the value you passed. - What the element refuses reaches you as the browser made it, such as the
NotAllowedErrorin step 3. - No error here is an
AppleMusicError, because the player asks Apple for nothing.
Next steps
- Show artwork for the track that is playing.
- Import a playlist: the songs
resolvefinds are tracks the player can play. - The reference for
previewPlayerandusePreviewPlayer.