Porting a v2 extension to v3
Most of a v2 extension’s API calls carry over. stdlib’s client object returns the same wrapper objects as Spicetify, so player, platform, URI, storage, notification, and context-menu calls work once you replace Spicetify. with client.. For a new module, start with Building a module instead.
If the port needs polling, copied class hashes, DOM surgery, monkey-patching, or private webpack searches, open an issue with the use case, the v2 API you’re replacing, and your smallest workaround. Meanwhile, keep the workaround bounded, feature-detected, and removed on unload.
1. List what the extension owns
A v3 module can load and unload several times in one Spotify session, so anything it leaves behind duplicates buttons, listeners, and behavior. Before you move code, list what the extension creates or touches, as your teardown checklist:
- Timers,
setTimeoutreadiness loops, andMutationObserver,ResizeObserver, andIntersectionObserverinstances. - Player, history, keyboard, document, and window listeners.
- Top-bar, playbar, menu, context-menu, modal, and panel UI.
- DOM nodes, React roots, tooltips, and
<style>elements it creates. - Spotify class names copied from DevTools.
- Webpack lookups, source patches, and direct access to internal modules.
localStoragekeys and other saved data.
2. Scaffold the module
Create the module next to the old source:
npm create spicetify-module my-extension -- --template extensioncd my-extensionnpm installnpm run devnpm run dev replaces spicetify config extensions and spicetify apply, and pushes each save into the running client. The id must be kebab-case, because it’s also the store id and folder name. Scaffold a project describes the generated files.
3. Replace the readiness loop
A typical v2 extension starts itself and retries until the wrapper is ready:
(function start() { if (!Spicetify.Player || !Spicetify.Platform) { setTimeout(start, 100); return; }
const onSongChange = () => console.log(Spicetify.Player.data?.item?.name); Spicetify.Player.addEventListener('songchange', onSongChange);})();The loader calls the module’s load export after the client API is ready, so the loop goes away. Move the extension body into the default export of mod.tsx and register its cleanup:
import { client, type ModuleRuntimeContext } from '/modules/stdlib/mod.ts';
export default async function (ctx: ModuleRuntimeContext) { const onSongChange = () => { console.log(client.player.data?.item?.name); };
client.player.addEventListener('songchange', onSongChange); ctx.defer(() => { client.player.removeEventListener('songchange', onSongChange); });}Don’t replace the loop with an unbounded await, because it blocks every later module (see Write the entry point).
4. Make every side effect reversible
Register a disposer right after you create something:
export default async function (ctx: ModuleRuntimeContext) { const interval = window.setInterval(refresh, 5_000); ctx.defer(() => window.clearInterval(interval));
const observer = new MutationObserver(refresh); observer.observe(document.body, { childList: true, subtree: true }); ctx.defer(() => observer.disconnect());}v2 helpers you keep using need the same treatment. After item.register() on a client.contextMenu.Item, call ctx.defer(() => item.deregister()). If an API returns its own cleanup function, pass it to ctx.defer. For DOM or React UI you mount yourself, unmount the root and remove the host element there too.
5. Move UI onto the registrar
Create one registrar with createRegistrar(ctx). Everything you register through it is removed when the module unloads. Replace each v2 UI call with its v3 counterpart:
| v2 | v3 |
|---|---|
new Spicetify.Topbar.Button(...) | registrar.placeButton('topbar-left' | 'topbar-right', …) |
new Spicetify.Playbar.Button(...) | registrar.placeButton('playbar', …), or a playbarButton component when its state changes |
new Spicetify.Playbar.Widget(...) | The playbarWidget register |
| A settings item in the profile menu | settingsRow or settingsSection on the Spicetify Settings page |
| Another profile-menu action | The menu register with the MenuItem component |
| A custom app page | navlink plus registerRoute() |
Spicetify.Panel (not in v3) | registrar.registerPanel() |
A popup or overlay on body | displayModal(), or the rootChild register |
| A track context-menu action | client.contextMenu.Item, deregistered on unload |
stdlib keeps the v2 Topbar.Button and Playbar.Button constructors working so an unported extension still runs, but a module uses the registrar. Add UI has examples for the other registers.
6. Use React from stdlib
Import React from /modules/stdlib/mod.ts instead of bundling it. Keep the old component bodies, but replace Spicetify.ReactComponent internals with stdlib components. The API reference marks wrapper members that are missing or reduced in v3.
7. Replace hashed class names
The extension’s class names fall into three groups:
- Classes you own, such as
.my-extension-card. Keep them. - A Spotify class your TS or TSX applies. Use a stdlib component that applies it for you, or a
MAP.*path such asMAP.main.playbar.buttons.button.wrapper. - CSS that targets Spotify’s DOM. Use a stable
main-*selector from the css-map when one exists.
Never copy a generated value such as sXjzYBob5Y4psogB into the module, because it stops matching after a Spotify update. Direct MAP.* use needs a stdlibBoundary exception (see Reference Spotify class names). If the path or selector you need doesn’t exist, ask for a classmap or css-map addition in an issue.
8. Move injected CSS into the CSS entry
Delete the code that appends a <style> element and move its rules to index.scss. The build emits index.css, and the loader adopts it before load and removes it on unload. Add "css": "index.css" to entries in metadata.json, because the extension template ships without CSS.
Scope every rule under a class or data attribute you own, and use --spice-* variables such as var(--spice-text) so the rules follow the active theme. Test with at least one light theme and one dark theme.
9. Rethink webpack lookups and source patches
Code that reaches into Spotify’s internals needs more than a rename:
- Use a typed stdlib export instead of scanning webpack yourself.
- When an optional export is missing, disable that one feature instead of throwing when the module loads.
- Don’t destructure a lookup result at the top level of a file. When the lookup drifts, the whole file fails.
- v3 doesn’t apply source transforms by default, so rebuild a feature that rewrote Spotify’s bundle on a wrapper or stdlib API, or let it degrade until one exists.
client.graphQL.Definitionslists the operations found in the running client. Check that an operation exists before you call it, because v2 operation names and hashes can be gone. See GraphQL for the CLI version this needs.
10. Keep saved data and split out logic
Keep the old storage keys so users keep their settings. client.storage behaves like Spicetify.LocalStorage, as in client.storage.get('my-extension:settings'). createStorage(ctx) from stdlib namespaces keys per module, which suits new data, but switching to it means migrating the old keys yourself.
Move parsing, filtering, state transitions, and formatting into named exports in logic.ts, and keep browser, React, and client access in mod.tsx, so tests can import the logic without a client. Declare every module you import at runtime in dependencies.
11. Test by unloading
With npm run dev running, go through the full lifecycle:
- Use every button, menu item, setting, route, and listener.
- Disable the module from the Module Store’s Installed tab, or run
Spicetify.Modules.disable('my-extension')in DevTools. - Check that its UI, styles, listeners, observers, timers, and overlays are gone.
- Enable it again and check that there’s exactly one of everything.
- Navigate away from its routes and back.
- Restart Spotify and check that saved settings load.
Then run npm run check and npm test. Spicetify.Modules.report in DevTools lists the modules that loaded and the reason each failed module failed.
12. Package and publish
Build and sideload the zip users will install. Then add preview, repository, and license to metadata.json, and publish a checksummed artifact and vault entry, which replace the Marketplace listing.
Next steps
The modules repository has ported examples: auto-skip-explicit (behavior and one setting), trashbin and shuffle-plus (context menus, playbar buttons, and settings sections), bookmark (a panel), and new-releases (a page).