Skip to content

Building a module

A module is the one thing you build for Spicetify v3. It can add a button, register a whole page, restyle the client, or all three. This page is the path from nothing to something running in your client; the module standard is the contract it has to meet.

Already have a classic extension? Follow Porting a v2 extension to v3 for the lifecycle, cleanup, UI, classmap, and packaging changes.

Scaffold and run

Terminal window
npm create spicetify-module my-module
cd my-module
npm run dev -- --launch

dev rebuilds on every save and pushes the result into a running Spotify in about a second, with no re-apply and no restart. --launch starts (or reuses) Spotify with the remote debugging port; without it, start Spotify yourself with --remote-debugging-port=9229.

Drop the pushed override when you are done:

Spicetify.Modules.removeLocal('my-module');

Templates: --template basic (a button and a route), extension (behaviour only), app (a nav entry and a full page), theme (CSS only, no TypeScript).

What the scaffold gives you

FileRole
metadata.jsonid, version, entries, dependencies, and the store card’s data
index.tsthe loader entry shim, leave it alone
mod.tsxyour module
logic.tsdependency-free logic, unit testable in Node
index.scssstyles, adopted as a stylesheet and removed on unload

The entry point

A module default-exports one function, and the loader awaits it:

import { createRegistrar } from '/modules/stdlib/mod.ts';
import type { ModuleRuntimeContext } from '/modules/stdlib/mod.ts';
export default async function (ctx: ModuleRuntimeContext) {
const registrar = createRegistrar(ctx);
// register your UI here
ctx.defer(() => {
// clear the timers, listeners and overlays you own
});
}

Two rules that matter more than they look:

Adding UI

Buttons go through placeButton, which handles ordering and placement for you:

registrar.placeButton('playbar', {
label: 'Loop section',
icon: LOOP_ICON,
onClick: toggleLoop,
near: { anchor: 'playbar:queue', side: 'before' },
});

Locations are topbar-left, topbar-right and playbar. near places the button next to one of Spotify’s own controls by a stable name (playbar:lyrics, playbar:queue, playbar:mute, playbar:miniplayer, playbar:fullscreen) rather than a selector that changes with every client build. If the anchor cannot be found the button falls back to ordinary placement, so it is never hidden.

A page is a nav entry plus a route:

registrar.register(
'navlink',
<NavLink localizedApp="My Module" appRoutePath={ROUTE} icon={ICON} activeIcon={ICON} />,
);
registrar.registerRoute(ROUTE, <Page />);

Settings rows from every module render together under one Spicetify section in Spotify’s own settings page, so a module with a single toggle does not need a page of its own.

The same register system covers menus, top-bar and playbar controls, panels, overlays and root-level UI. Prefer those owned surfaces and stdlib’s React or vanilla primitives over inserting raw DOM into Spotify’s private structure: the register handles placement and cleanup, while the primitives share the native-looking control contract.

The client capability surface

Import client from stdlib instead of reading the ambient compatibility global throughout module code:

import { client } from '/modules/stdlib/mod.ts';
client.player.next();
client.platform.History.push('/search');
client.notify('Done');

It provides typed, lazy capabilities for player, platform, URI, icons, networking, storage, keyboard, context menus, notifications, and the other client services. stdlib currently adapts the compatibility wrapper internally, but keeping that access behind one boundary lets the implementation change without rewriting every module. See the API reference for the underlying behavior.

Reach for a native client.platform.*API before making an HTTP call of your own.

Class names

Spotify’s own class names are hashed and change with every client build, so never hardcode one. Reference them through MAP:

const cls = MAP.main.topbar.right.button_t.wrapper;

Modules ship with those references intact and the CLI resolves them at apply time against the exact Spotify version installed, which is why one build of your module works on every supported client. classmap.d.ts is generated for you, so the paths autocomplete.

Themes

Start a new CSS-only theme module from the scaffold:

Terminal window
npm create spicetify-module my-theme -- --template theme

Or migrate a classic theme containing color.ini and user.css:

Terminal window
spicetify-kit from-theme /path/to/classic-theme --name my-theme

The migration copies the classic CSS and color.ini; at runtime, each INI section becomes a switchable scheme whose colours are exposed as --spice-* variables. Treat the result as a starting point: run it through the live dev loop, update selectors that no longer match, and never replace them with Spotify’s generated class hashes.

Testing

Put anything worth testing in logic.ts, free of client imports, and pass plain values from client into it from mod.tsx. Then:

Terminal window
npm run check # typecheck
npm run test # unit tests

UI is verified live through the dev loop, because JSX and the client’s runtime URLs do not resolve in Node.

Build and pack

Terminal window
spicetify-kit build # bundles TS/TSX and compiles index.scss
spicetify-kit pack dist/my-module@1.0.0 # zips it and prints the sha256

build enforces the standard’s error tier: bad metadata or a missing loader shim aborts the build rather than producing something that fails at boot. spicetify-kit check runs the same audit on its own.

To sideload a packed build into a running client without publishing:

Terminal window
spicetify-kit install my-module@1.0.0.zip

Then publish

Publishing covers getting it into the store.