Let’s be honest: I only wanted a little overlay in OBS showing what’s playing on Spotify.
Turns out that meant spinning up a Node.js service, figuring out Spotify auth, hooking up WebSocket to OBS, and sprinkling in a few animations. Simple idea, overengineered solution. I love it.
Here’s what I ended up with: a slick, live-updating overlay showing the current song, artist, and album art — all piped in through a custom Node.js backend and displayed in OBS using a browser source.

Features
Before jumping into code, let me outline what this overlay actually does:
-
Live Song Updates
Every time the track changes on Spotify, the overlay updates instantly. No browser refreshes, no polling. Just real-time WebSocket updates powered by a backend service.
-
Custom Styling
Since the overlay is rendered with HTML/CSS inside OBS, you have full control over the look. Fancy gradients? Smooth transitions? Rounded album art with a drop shadow? Yes to all.
-
Now Playing Only
I kept it minimal: it shows the current track info, nothing more. No play/pause buttons, no volume bar. Just what you need to flex your music taste on stream.
The Stack
Let’s talk tech. Here’s what I used:
- Node.js: A tiny Express server to serve the overlay and pipe data via WebSocket.
- Spotify Web API: For fetching the current playback data.
- OBS WebSocket Plugin (optional): Could be used for syncing with scene changes or triggers.
- HTML/CSS/JS: The overlay itself is just a browser page — the simpler the better.
Spotify API Setup
You’ll need a Spotify Developer account and an app set up at developer.spotify.com.
Make sure to set your Redirect URI properly, e.g. http://localhost:3000/callback
.
Grab your:
SPOTIFY_CLIENT_ID=...
SPOTIFY_CLIENT_SECRET=...
SPOTIFY_REFRESH_TOKEN=...
I recommend using the refresh token method so your overlay doesn’t die after an hour.
To generate it, I followed this guide — fast and painless.
Backend Service
This is the brain of the operation.
Every 5–10 seconds (or via Spotify’s player events), we poll the current track info and emit it via WebSocket to all connected clients.
Sample express setup:
import express from 'express';
import { Server } from 'ws';
import { getCurrentPlayback } from './spotify';
const app = express();
const server = app.listen(3000);
const wss = new Server({ server });
setInterval(async () => {
const data = await getCurrentPlayback();
wss.clients.forEach(client => {
if (client.readyState === 1) {
client.send(JSON.stringify(data));
}
});
}, 5000);
The getCurrentPlayback()
function calls Spotify's /v1/me/player/currently-playing
endpoint using your refresh token flow.
Frontend Overlay
The overlay itself is just a tiny HTML page that connects to the WebSocket and updates the DOM accordingly.
<script>
const socket = new WebSocket('ws://localhost:3000');
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
document.getElementById('track').innerText = `${data.name} — ${data.artist}`;
document.getElementById('artwork').src = data.albumArt;
};
</script>
And the structure:
<div id="overlay">
<img id="artwork" />
<div id="track">Loading...</div>
</div>
Styled with CSS — keep it minimal for best performance in OBS. I added some @keyframes
for fade-in when tracks change.
OBS Integration
To add the overlay into OBS:
- Add a new Browser Source.
- Set the URL to
http://localhost:3000/overlay
. - Match your canvas size (e.g. 800x200).
- Make sure Control audio via OBS is unchecked.
Done.
Every time the track changes, your OBS overlay updates automatically — no plugins, no hacks.
Final Result
This is what it looks like in action:
Summary
Was it overkill for a simple overlay? Maybe. But now I have:
- A custom Node.js server I can reuse for more overlay logic.
- Full creative control over how the UI looks.
- Zero third-party dependencies (except Spotify itself).
Would I do it again? Absolutely.
The Full Code
#!/usr/bin/env node
import OBSWebSocket from 'obs-websocket-js';
import { exec as execCb } from 'child_process';
import { promisify } from 'util';
import fs from 'fs/promises';
const exec = promisify(execCb);
// Config
const config = {
obsUrl: 'ws://127.0.0.1:4455',
obsPassword: 'JeQic9cGJ2DCOcRT',
sources: [
{ name: 'Spotify', type: 'source', sceneName: 'overlay' },
{ name: 'Spotify Card', type: 'folder', sceneName: 'overlay' }
],
reloadDelay: 500,
cacheFile: './.spotify-current-song.json',
checkInterval: 5000
};
// Spotify helpers
async function getCurrentSong() {
try {
const { stdout } = await exec('sp current');
const lines = stdout.trim().split('\n');
const info = Object.fromEntries(lines.map(line => {
const [key, ...rest] = line.trim().split(/\s+/);
return [key, rest.join(' ')];
}));
return {
album: info.Album || '',
artist: info.Artist || '',
title: info.Title || '',
albumArtist: info.AlbumArtist || ''
};
} catch (err) {
console.error('Failed to get current song:', err.message);
return null;
}
}
async function isSpotifyPlaying() {
try {
const { stdout } = await exec('sp status');
return stdout.trim() === 'Playing';
} catch (err) {
console.error('Failed to check Spotify status:', err.message);
return false;
}
}
// Cache helpers
async function readCachedSong() {
try {
const data = await fs.readFile(config.cacheFile, 'utf8');
return JSON.parse(data);
} catch {
return {};
}
}
async function writeCachedSong(info) {
try {
await fs.writeFile(config.cacheFile, JSON.stringify(info), 'utf8');
} catch (err) {
console.error('Failed to write cache file:', err.message);
}
}
// OBS control
async function toggleSourceVisibility(obs, name, scene, visible) {
const { sceneItems } = await obs.call('GetSceneItemList', { sceneName: scene });
const item = sceneItems.find(i => i.sourceName === name);
if (!item) {
throw new Error(`Source "${name}" not found in scene "${scene}"`);
}
console.log(`Toggling "${name}" to ${visible ? 'visible' : 'hidden'}`);
await obs.call('SetSceneItemEnabled', {
sceneName: scene,
sceneItemId: item.sceneItemId,
sceneItemEnabled: visible
});
}
async function reloadOBSSources(doReload = true) {
const obs = new OBSWebSocket();
try {
console.log(`Connecting to OBS at ${config.obsUrl}...`);
await obs.connect(config.obsUrl, config.obsPassword);
console.log('Connected.');
for (const source of config.sources) {
await toggleSourceVisibility(obs, source.name, source.sceneName, false);
}
if (!doReload) return;
console.log(`Waiting ${config.reloadDelay}ms...`);
await new Promise(r => setTimeout(r, config.reloadDelay));
for (const source of config.sources) {
await toggleSourceVisibility(obs, source.name, source.sceneName, true);
}
console.log('OBS sources refreshed.');
} catch (err) {
console.error('OBS error:', err.message);
} finally {
await obs.disconnect().catch(() => {});
}
}
// Song check loop
async function checkForSongChanges() {
if (!(await isSpotifyPlaying())) {
console.log('Spotify not playing. Skipping.');
await reloadOBSSources(false);
return;
}
const current = await getCurrentSong();
if (!current) return;
const cached = await readCachedSong();
const hasChanged = ['title', 'artist', 'album'].some(key => current[key] !== cached[key]);
if (hasChanged) {
console.log(`Song changed: ${cached.artist || 'N/A'} - ${cached.title || 'N/A'} → ${current.artist} - ${current.title}`);
await writeCachedSong(current);
await reloadOBSSources();
} else {
console.log(`No change: ${current.artist} - ${current.title}`);
}
}
// Entry points
async function runContinuous() {
console.log(`Checking every ${config.checkInterval / 1000}s...`);
await checkForSongChanges();
setInterval(checkForSongChanges, config.checkInterval);
}
async function runOnce() {
console.log('Single check...');
await checkForSongChanges();
}
async function forceRefresh() {
console.log('Forcing refresh...');
await reloadOBSSources();
}
// CLI arg routing
const args = process.argv.slice(2);
if (args.includes('--once')) runOnce();
else if (args.includes('--force')) forceRefresh();
else runContinuous();
Tech Recap
See you next post 👋 And if you end up building this yourself, share it with me — I’d love to see what you make.