Skip to content
Client
Server

Set up your server ​

Your server holds your MusicKit key. It signs developer tokens, hands them to your pages, and calls Apple for a listener who has signed in. In this guide you add the SDK to it, give it your key, make its one client, and run it.

Before you start, you need:

The code below comes from the sample apps' servers, for the server you pick in the nav: Express, Cloudflare Workers, or a Next.js app, which is its own server.

1. Install the package ​

sh
pnpm add @open-music-sdk/server express
sh
pnpm add @open-music-sdk/server

The package runs on Workers as it is. It needs nothing but fetch and WebCrypto, which Workers have.

sh
pnpm add @open-music-sdk/server @open-music-sdk/react

This is for a Next.js 16 app that uses the App Router. Route handlers, server components and server actions use @open-music-sdk/server, which can hold your private key. Client components use @open-music-sdk/react, which can't.

@open-music-sdk/react puts the "use client" directive on the one file that holds MusicProvider and the hooks, and on nothing else. So what else it exports, such as artworkImage, isAppleMusicError or musicQueries, carries no directive, and you can import it into a route handler or a server component too.

2. Give it your key ​

The SDK reads no environment variable and no file: your server reads the key and its two IDs, and hands them over. These docs keep them in three variables, with a fourth for the origin your pages are served from, which every developer token your server hands out is bound to:

dotenv
APPLE_MUSIC_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIGT…\n-----END PRIVATE KEY-----"
APPLE_MUSIC_TEAM_ID="ABCDE12345"
APPLE_MUSIC_KEY_ID="ABC123DEFG"
PAGE_ORIGIN="http://localhost:2728"

The key is the contents of the .p8 file on one line, with each line break written as \n, which the SDK reads.

On your machine, put them in a .env file beside your server's package.json, and have Node load it:

sh
node --env-file=.env src/server.ts

Node 24 runs TypeScript as it is, so the server needs no build.

On your machine, put them in a .dev.vars file beside your Wrangler config, which wrangler dev reads.

If a Vite dev server runs in the same repository, as a page's often does, name the file .env instead, which wrangler dev reads when there is no .dev.vars. Vite's dev server serves any file in the repository to a page that asks for it, except those on its deny list, and that list names .env but not .dev.vars. The sample apps do this.

A deployed Worker takes them as secrets instead: see Deploy a Worker.

On your machine, put them in .env.local beside your package.json. Next loads it for your server's code, and hands none of it to the page unless a name starts with NEXT_PUBLIC_, which these must never do.

Never commit the file

It holds your private key. Add it to .gitignore before you write the key into it.

3. Make its client once ​

Make one client for the whole server, as it starts. The Express sample reads its settings first, so a missing one stops the server at once, with the setting's name:

ts
/** A setting from .env, which has to be there. */
function required(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(
      `${name} is not set. Copy samples/.env.example to .env in this `
        + "server's folder and fill it in.",
    );
  }
  return value;
}

const key = {
  pem: required("APPLE_MUSIC_PRIVATE_KEY"),
  teamId: required("APPLE_MUSIC_TEAM_ID"),
  keyId: required("APPLE_MUSIC_KEY_ID"),
};
// The page the developer token is bound to. A deployed copy has to name its
// own: there is no default to forget.
const pageOrigin = required("PAGE_ORIGIN");

// One client for the whole server. Every listener's requests count against the
// same developer token, so they share one limiter. The store loses its tokens
// when the server stops: a real app keeps them somewhere lasting.
const store = new MemoryUserTokenStore();
const music = createServerClient({
  ...key,
  userTokenStore: store,
  rateLimit: createRateLimiter({ capacity: 20, refillPerSecond: 10 }),
});

MemoryUserTokenStore keeps listeners' tokens in the process, and loses them when it stops. See Choose a store for one that lasts.

Then make the app, and listen. The sample's app refuses a request sent by any name but its own or the page's, and listens on your machine's loopback address alone. The routes go between the two, as Serve a developer token to a page and Keep a listener's token on your server show:

ts
const app = express();

// Only requests sent to this server by its own name or the page's: a site whose
// name a DNS trick points at 127.0.0.1 would otherwise reach it from the
// listener's browser as if it were the page.
const hosts = new Set([new URL(pageOrigin).host, "127.0.0.1:8787"]);
app.use((req, res, next) => {
  if (hosts.has(req.headers.host ?? "")) next();
  else res.status(421).json({ error: "MisdirectedRequest" });
});
ts
// On this machine's loopback address alone: the samples are never meant to be
// reached from elsewhere.
app.listen(8787, "127.0.0.1", (error) => {
  if (error) throw error;
  console.log(
    `Listening on http://127.0.0.1:8787, handing tokens to ${pageOrigin}`,
  );
});

A Worker is handed its secrets with each request, in env. Make the client from them on the first request, and keep it in a module variable, as app ??= setUp(env) does:

ts
export default {
  async fetch(req: Request, env: tEnv): Promise<Response> {
    app ??= setUp(env);
    const answer = await route(req, app);
    if (sessionOf(req) !== undefined) return answer;
    // A request with no session gets one with its answer.
    const withSession = new Response(answer.body, answer);
    const session = crypto.randomUUID();
    withSession.headers.append(
      "set-cookie",
      `crate_session=${session}; Path=/api; HttpOnly; SameSite=Lax`,
    );
    return withSession;
  },
};
ts
// Made on the first request, from the secrets that request brings, and kept for
// every request after it: a client made per request would sign a new developer
// token every time.
let app: tApp | undefined;

function setUp(env: tEnv): tApp {
  const pem = env.APPLE_MUSIC_PRIVATE_KEY;
  const teamId = env.APPLE_MUSIC_TEAM_ID;
  const keyId = env.APPLE_MUSIC_KEY_ID;
  // The page the token is bound to has no default either: a deployed copy has
  // to name its own.
  const pageOrigin = env.PAGE_ORIGIN;
  if (!pem || !teamId || !keyId || !pageOrigin) {
    throw new Error(
      "The MusicKit key or PAGE_ORIGIN is not set. Copy "
        + "samples/.env.example to samples/worker-server/.env and fill it in.",
    );
  }
  const key = { pem, teamId, keyId };

  const store = new KvUserTokenStore(env.MUSIC_USER_TOKENS);
  const music = createServerClient({
    ...key,
    userTokenStore: store,
    rateLimit: createRateLimiter({ capacity: 20, refillPerSecond: 10 }),
  });
  // The page's developer token, minted for the page's origin alone and
  // short-lived, since anyone can read a page. The handler takes the Worker's
  // request as it comes: see route() below.
  const token = tokenEndpoint({ ...key, origin: [pageOrigin] });
  // Asks Apple whether the posted token works, and keeps it under the session's
  // id only then.
  const intake = userTokenIntake(music, { store, userId: sessionOf });
  // Requests sent to the Worker by its own name or the page's: a site whose
  // name a DNS trick points at 127.0.0.1 would otherwise reach it from the
  // listener's browser as if it were the page.
  const hosts = new Set([new URL(pageOrigin).host, "127.0.0.1:8787"]);
  return { music, store, token, intake, hosts };
}

Don't make it per request. A client made per request imports the key and signs a new token on every request. One kept in the module signs a token once, reuses it for every request the isolate serves, and replaces it in the background halfway through its life.

route hands each request to the Worker's routes, which Serve a developer token to a page and Keep a listener's token on your server show. sessionOf reads the sample's session cookie, where your app reads its own sessions.

The Worker keeps listeners' tokens in a Workers KV namespace, bound as MUSIC_USER_TOKENS in your Wrangler config, and wrangler dev listens on your machine's loopback address alone:

jsonc
{
  "name": "my-worker",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "kv_namespaces": [
    { "binding": "MUSIC_USER_TOKENS", "id": "00000000000000000000000000000000" }
  ],
  "dev": { "ip": "127.0.0.1", "port": 8787 }
}

wrangler dev keeps the namespace on your machine, so any id does until you deploy. Deploy a Worker makes a real one.

Make one client for your whole server, in a module only server code imports. The Next sample makes it, with its store of listeners' tokens, in src/lib/music.ts, from settings src/lib/env.ts reads:

ts
// Shows: createServerClient, createRateLimiter, MemoryUserTokenStore.
// Guide: /guides/server-setup
import {
  MemoryUserTokenStore,
  createRateLimiter,
  createServerClient,
  type tMusic,
  type tUserTokenStore,
} from "@open-music-sdk/server";
import { required } from "./env";

interface tServer {
  readonly music: tMusic;
  readonly store: tUserTokenStore;
}

// Kept on globalThis: Next can load this module more than once, in each
// route's bundle and again after an edit, and every copy has to find the same
// tokens.
const held = globalThis as { vinylCollector?: tServer };

/**
 * The server's one client and its store of listeners' tokens, made on first
 * use rather than as the module loads: `next build` loads the module with no
 * key in its environment, and builds nothing that needs one.
 */
function made(): tServer {
  if (held.vinylCollector !== undefined) return held.vinylCollector;
  // The store loses its tokens when the server stops: a real app keeps them
  // somewhere lasting, that every instance of it reaches.
  const store = new MemoryUserTokenStore();
  // Every listener's requests count against the same developer token, so
  // they share one limiter.
  const music = createServerClient({
    pem: required("APPLE_MUSIC_PRIVATE_KEY"),
    teamId: required("APPLE_MUSIC_TEAM_ID"),
    keyId: required("APPLE_MUSIC_KEY_ID"),
    // The storefront of a catalog call that names none, as the guides'
    // shorter examples do. The sample's own calls each name the storefront the
    // page is in.
    storefront: "us",
    userTokenStore: store,
    rateLimit: createRateLimiter({ capacity: 20, refillPerSecond: 10 }),
  });
  held.vinylCollector = { music, store };
  return held.vinylCollector;
}

export function getMusic(): tMusic {
  return made().music;
}

export function getStore(): tUserTokenStore {
  return made().store;
}
ts
/** A setting from .env.local, which has to be there. */
export function required(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(
      `${name} is not set. Copy samples/.env.example to `
        + "samples/web-nextjs/.env.local and fill it in.",
    );
  }
  return value;
}
  • The client is made the first time it's asked for, not as the module loads. next build loads every route's modules with no key in its environment, and builds nothing that needs one.
  • It's kept on globalThis, because Next can load the module more than once, in each route's bundle and again after an edit. Every copy has to find the same tokens.

MemoryUserTokenStore keeps tokens in one process only, and loses them on restart. If your app runs on more than one instance, or on serverless functions, use a store they all reach. See Choose a store.

The client's other uses on a Next server, acting for a listener in a server action and caching catalog reads, are in Use server actions and caching in Next.js.

The client signs one developer token, uses it for every request, and signs the next before it runs out. Its rateLimit is shared by every request your server makes for every listener, since they all spend the same token's limit.

4. Run it ​

sh
node --env-file=.env --watch src/server.ts

The Express sample listens on 127.0.0.1:8787 alone, so nothing but your own machine reaches it.

sh
npx wrangler dev

wrangler dev keeps the KV namespace's data on your machine, in .wrangler/state, in plain text. Delete the folder when you're done.

sh
pnpm next dev --port 2728 --hostname localhost

Your server and your pages run as one app, on one origin: the port of PAGE_ORIGIN. Without --hostname localhost, next dev listens on every network your machine is on, where anyone can reach your token route.

A server with no routes does nothing for a page yet. Its first route hands your pages a developer token.

Next steps ​

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