Showing “Now Playing” from the Spotify API in Framer
Showing “Now Playing” from the Spotify API in Framer
Feb 26, 2026
#Framer, #Portfolio #Spotify #Api

What do you add to a portfolio website besides about you, our work and credentials? For me, I want my portfolio to feel personal. It should reflect me.
One of my inspirations was Lee Robinson. A couple of years ago, he added little live stats to his site. He doesn’t do it anymore though. But, that gave me an idea: a personal site can be more than static pages. By connecting to APIs, it can become dynamic. It can reflect live data from the products and services you use.
Here’s one idea. Show what’s currently playing on my Spotify. It displays the track title, artist, and album art in real time.




I like it a lot. It’s a small detail, but it brings personality to the site, especially showing the album art. It adds texture and color to a website that’s mostly black and white. And because it’s connected to the Spotify API, it updates automatically whenever the song changes.
Thanks to Claude Code, ideas like this move much faster now. I gave it a prompt, adjusted a few details, and ended up with a working React component in Framer that fetches data from an endpoint on my domain. I host the endpoint as a small serverless function on Vercel. My traffic is low, so the free tier is more than enough.
Here's how the framer react component looks like:

Some variations you can get from customizing the properties:


And the empty state…

Then loading state…

If you’re curious, here’s the source code.
If you have no idea how to set it up, copy and paste the article and the code into Claude Code or Codex and ask it to wire it up for you
import { addPropertyControls, ControlType } from "framer" import { useEffect, useState } from "react" interface NowPlayingData { albumImageUrl: string title: string artist: string songUrl: string loaded: boolean } type Layout = "compact" | "spacious" type AlbumStyle = "square" | "cd" interface Props { apiUrl: string layout: Layout label: { text: string color: string font: object } track: { titleColor: string artistColor: string separator: string font: object } albumArt: { show: boolean style: AlbumStyle } content: { silenceText: string loadingText: string } } const layoutConfig = { compact: { albumSize: 32, albumRadius: 4, labelSize: 10, titleSize: 12, artistSize: 12, gap: 8, innerGap: 5, labelGap: 2, }, spacious: { albumSize: 64, albumRadius: 10, labelSize: 12, titleSize: 20, artistSize: 20, gap: 16, innerGap: 6, labelGap: 4, }, } const ANIMATIONS = ` @keyframes marquee-scroll { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } } @keyframes cd-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes skeleton-shine { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } ` export default function SpotifyNowPlaying({ apiUrl, layout, label, track, albumArt, content, }: Props) { const [imgLoaded, setImgLoaded] = useState(false) const [data, setData] = useState<NowPlayingData>({ albumImageUrl: "", title: "", artist: "", songUrl: "", loaded: false, }) useEffect(() => { fetch(apiUrl) .then((res) => res.json()) .then((json) => { const hasTrackData = json.isPlaying && json.title && json.songUrl if (hasTrackData) { setData({ albumImageUrl: json.albumImageUrl, title: json.title, artist: json.artist, songUrl: json.songUrl, loaded: true, }) } else { setData({ albumImageUrl: "", title: content.silenceText, artist: "", songUrl: "", loaded: true, }) } }) .catch(() => { setData({ albumImageUrl: "", title: "Couldn't load now playing", artist: "", songUrl: "", loaded: true, }) }) }, [apiUrl, content.silenceText]) useEffect(() => { setImgLoaded(false) }, [data.albumImageUrl]) const { albumSize, albumRadius, labelSize, titleSize, artistSize, gap, innerGap, labelGap, } = layoutConfig[layout] ?? layoutConfig.compact const trackTitle = data.loaded ? data.title : content.loadingText const hasArtist = data.title.trim() !== "" && data.artist.trim() !== "" // Speed: roughly 1s per 4 chars, min 6s const marqueeDuration = Math.max( 6, (trackTitle.length + (data.artist?.length ?? 0)) * 0.25 ) const handleClick = () => { if (data.songUrl) window.open(data.songUrl, "_blank") } const isCD = albumArt.style === "cd" const imgBorderRadius = isCD ? "50%" : albumRadius // Show album art area only while loading (skeleton) or when a song is playing const showAlbumArea = albumArt.show && (!data.loaded || !!data.albumImageUrl) return ( <> <style>{ANIMATIONS}</style> <div onClick={handleClick} style={{ display: "flex", flexDirection: "row", alignItems: "center", gap, cursor: data.songUrl ? "pointer" : "default", width: "100%", height: "100%", overflow: "hidden", }} > {/* Album art */} {showAlbumArea && ( <div style={{ position: "relative", width: albumSize, height: albumSize, flexShrink: 0, borderRadius: imgBorderRadius, overflow: "hidden", }} > {/* Skeleton — only while fetching or image not yet loaded */} {(!data.loaded || !imgLoaded) && ( <div style={{ position: "absolute", inset: 0, background: "linear-gradient(90deg, #d0d0d0 25%, #e8e8e8 50%, #d0d0d0 75%)", backgroundSize: "200% 100%", animationName: "skeleton-shine", animationDuration: "1.5s", animationTimingFunction: "linear", animationIterationCount: "infinite", }} /> )} {/* Image */} {data.albumImageUrl && ( <img src={data.albumImageUrl} alt={`${data.title} album art`} onLoad={() => setImgLoaded(true)} style={{ width: albumSize, height: albumSize, objectFit: "cover", display: "block", opacity: imgLoaded ? 1 : 0, animationName: isCD ? "cd-spin" : "none", animationDuration: "5s", animationTimingFunction: "linear", animationIterationCount: "infinite", }} /> )} {/* CD center hole */} {isCD && imgLoaded && ( <div style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)", width: albumSize * 0.2, height: albumSize * 0.2, borderRadius: "50%", background: "white", boxShadow: "inset 0 0 3px rgba(0,0,0,0.25)", pointerEvents: "none", }} /> )} </div> )} {/* Text column */} <div style={{ display: "flex", flexDirection: "column", gap: labelGap, overflow: "hidden", flex: 1, minWidth: 0, }} > {/* Now playing label */} <span style={{ fontSize: labelSize, color: label.color, opacity: 1, whiteSpace: "nowrap", textTransform: "uppercase", letterSpacing: "0.06em", ...label.font, }} > {label.text} </span> {/* Compact: inline marquee (disabled for silence) */} {layout === "compact" ? ( <div style={{ overflow: "hidden", width: "100%" }}> <div style={{ display: "inline-flex", alignItems: "center", whiteSpace: "nowrap", animationName: data.songUrl ? "marquee-scroll" : "none", animationDuration: `${marqueeDuration}s`, animationTimingFunction: "linear", animationIterationCount: "infinite", }} > {/* Duplicate for seamless loop */} {(data.songUrl ? [0, 1] : [0]).map((i) => ( <span key={i} style={{ display: "inline-flex", alignItems: "center", gap: innerGap, paddingRight: 15, }} > <span style={{ fontSize: titleSize, color: track.titleColor, ...track.font, }} > {trackTitle} </span> {hasArtist && ( <> <span style={{ fontSize: titleSize, color: track.titleColor, ...track.font, }} > {track.separator} </span> <span style={{ fontSize: artistSize, color: track.artistColor, ...track.font, }} > {data.artist} </span> </> )} </span> ))} </div> </div> ) : ( /* Spacious: inline, no marquee */ <div style={{ display: "flex", flexDirection: "row", alignItems: "center", gap: innerGap, overflow: "hidden", }} > <span style={{ fontSize: titleSize, color: track.titleColor, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", flexShrink: 0, ...track.font, }} > {trackTitle} </span> {hasArtist && ( <> <span style={{ fontSize: titleSize, color: track.titleColor, flexShrink: 0, ...track.font, }} > {track.separator} </span> <span style={{ fontSize: artistSize, color: track.artistColor, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", ...track.font, }} > {data.artist} </span> </> )} </div> )} </div> </div> </> ) } SpotifyNowPlaying.defaultProps = { apiUrl: "https://[yourdomain]/api/now-playing", layout: "compact", label: { text: "Now Playing", color: "#000000", font: {}, }, track: { titleColor: "#000000", artistColor: "#000000", separator: "—", font: {}, }, albumArt: { show: true, style: "square", }, content: { silenceText: "I'm currently enjoying the silence", loadingText: "Loading...", }, } addPropertyControls(SpotifyNowPlaying, { apiUrl: { type: ControlType.String, title: "API URL", }, layout: { type: ControlType.Enum, title: "Layout", options: ["compact", "spacious"], optionTitles: ["Compact", "Spacious"], }, albumArt: { type: ControlType.Object, title: "Album Art", controls: { show: { type: ControlType.Boolean, title: "Show", }, style: { type: ControlType.Enum, title: "Style", options: ["square", "cd"], optionTitles: ["Square", "Spinning CD"], hidden: (props: { show: boolean }) => !props.show, }, }, }, label: { type: ControlType.Object, title: "Label", controls: { text: { type: ControlType.String, title: "Text", }, color: { type: ControlType.Color, title: "Color", }, font: { type: ControlType.Font, title: "Font", controls: "extended", }, }, }, track: { type: ControlType.Object, title: "Track", controls: { titleColor: { type: ControlType.Color, title: "Title Color", }, artistColor: { type: ControlType.Color, title: "Artist Color", }, separator: { type: ControlType.String, title: "Separator", }, font: { type: ControlType.Font, title: "Font", controls: "extended", }, }, }, content: { type: ControlType.Object, title: "Content", controls: { silenceText: { type: ControlType.String, title: "Silence Text", }, loadingText: { type: ControlType.String, title: "Loading Text", }, }, }, })
import { addPropertyControls, ControlType } from "framer" import { useEffect, useState } from "react" interface NowPlayingData { albumImageUrl: string title: string artist: string songUrl: string loaded: boolean } type Layout = "compact" | "spacious" type AlbumStyle = "square" | "cd" interface Props { apiUrl: string layout: Layout label: { text: string color: string font: object } track: { titleColor: string artistColor: string separator: string font: object } albumArt: { show: boolean style: AlbumStyle } content: { silenceText: string loadingText: string } } const layoutConfig = { compact: { albumSize: 32, albumRadius: 4, labelSize: 10, titleSize: 12, artistSize: 12, gap: 8, innerGap: 5, labelGap: 2, }, spacious: { albumSize: 64, albumRadius: 10, labelSize: 12, titleSize: 20, artistSize: 20, gap: 16, innerGap: 6, labelGap: 4, }, } const ANIMATIONS = ` @keyframes marquee-scroll { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } } @keyframes cd-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes skeleton-shine { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } ` export default function SpotifyNowPlaying({ apiUrl, layout, label, track, albumArt, content, }: Props) { const [imgLoaded, setImgLoaded] = useState(false) const [data, setData] = useState<NowPlayingData>({ albumImageUrl: "", title: "", artist: "", songUrl: "", loaded: false, }) useEffect(() => { fetch(apiUrl) .then((res) => res.json()) .then((json) => { const hasTrackData = json.isPlaying && json.title && json.songUrl if (hasTrackData) { setData({ albumImageUrl: json.albumImageUrl, title: json.title, artist: json.artist, songUrl: json.songUrl, loaded: true, }) } else { setData({ albumImageUrl: "", title: content.silenceText, artist: "", songUrl: "", loaded: true, }) } }) .catch(() => { setData({ albumImageUrl: "", title: "Couldn't load now playing", artist: "", songUrl: "", loaded: true, }) }) }, [apiUrl, content.silenceText]) useEffect(() => { setImgLoaded(false) }, [data.albumImageUrl]) const { albumSize, albumRadius, labelSize, titleSize, artistSize, gap, innerGap, labelGap, } = layoutConfig[layout] ?? layoutConfig.compact const trackTitle = data.loaded ? data.title : content.loadingText const hasArtist = data.title.trim() !== "" && data.artist.trim() !== "" // Speed: roughly 1s per 4 chars, min 6s const marqueeDuration = Math.max( 6, (trackTitle.length + (data.artist?.length ?? 0)) * 0.25 ) const handleClick = () => { if (data.songUrl) window.open(data.songUrl, "_blank") } const isCD = albumArt.style === "cd" const imgBorderRadius = isCD ? "50%" : albumRadius // Show album art area only while loading (skeleton) or when a song is playing const showAlbumArea = albumArt.show && (!data.loaded || !!data.albumImageUrl) return ( <> <style>{ANIMATIONS}</style> <div onClick={handleClick} style={{ display: "flex", flexDirection: "row", alignItems: "center", gap, cursor: data.songUrl ? "pointer" : "default", width: "100%", height: "100%", overflow: "hidden", }} > {/* Album art */} {showAlbumArea && ( <div style={{ position: "relative", width: albumSize, height: albumSize, flexShrink: 0, borderRadius: imgBorderRadius, overflow: "hidden", }} > {/* Skeleton — only while fetching or image not yet loaded */} {(!data.loaded || !imgLoaded) && ( <div style={{ position: "absolute", inset: 0, background: "linear-gradient(90deg, #d0d0d0 25%, #e8e8e8 50%, #d0d0d0 75%)", backgroundSize: "200% 100%", animationName: "skeleton-shine", animationDuration: "1.5s", animationTimingFunction: "linear", animationIterationCount: "infinite", }} /> )} {/* Image */} {data.albumImageUrl && ( <img src={data.albumImageUrl} alt={`${data.title} album art`} onLoad={() => setImgLoaded(true)} style={{ width: albumSize, height: albumSize, objectFit: "cover", display: "block", opacity: imgLoaded ? 1 : 0, animationName: isCD ? "cd-spin" : "none", animationDuration: "5s", animationTimingFunction: "linear", animationIterationCount: "infinite", }} /> )} {/* CD center hole */} {isCD && imgLoaded && ( <div style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)", width: albumSize * 0.2, height: albumSize * 0.2, borderRadius: "50%", background: "white", boxShadow: "inset 0 0 3px rgba(0,0,0,0.25)", pointerEvents: "none", }} /> )} </div> )} {/* Text column */} <div style={{ display: "flex", flexDirection: "column", gap: labelGap, overflow: "hidden", flex: 1, minWidth: 0, }} > {/* Now playing label */} <span style={{ fontSize: labelSize, color: label.color, opacity: 1, whiteSpace: "nowrap", textTransform: "uppercase", letterSpacing: "0.06em", ...label.font, }} > {label.text} </span> {/* Compact: inline marquee (disabled for silence) */} {layout === "compact" ? ( <div style={{ overflow: "hidden", width: "100%" }}> <div style={{ display: "inline-flex", alignItems: "center", whiteSpace: "nowrap", animationName: data.songUrl ? "marquee-scroll" : "none", animationDuration: `${marqueeDuration}s`, animationTimingFunction: "linear", animationIterationCount: "infinite", }} > {/* Duplicate for seamless loop */} {(data.songUrl ? [0, 1] : [0]).map((i) => ( <span key={i} style={{ display: "inline-flex", alignItems: "center", gap: innerGap, paddingRight: 15, }} > <span style={{ fontSize: titleSize, color: track.titleColor, ...track.font, }} > {trackTitle} </span> {hasArtist && ( <> <span style={{ fontSize: titleSize, color: track.titleColor, ...track.font, }} > {track.separator} </span> <span style={{ fontSize: artistSize, color: track.artistColor, ...track.font, }} > {data.artist} </span> </> )} </span> ))} </div> </div> ) : ( /* Spacious: inline, no marquee */ <div style={{ display: "flex", flexDirection: "row", alignItems: "center", gap: innerGap, overflow: "hidden", }} > <span style={{ fontSize: titleSize, color: track.titleColor, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", flexShrink: 0, ...track.font, }} > {trackTitle} </span> {hasArtist && ( <> <span style={{ fontSize: titleSize, color: track.titleColor, flexShrink: 0, ...track.font, }} > {track.separator} </span> <span style={{ fontSize: artistSize, color: track.artistColor, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", ...track.font, }} > {data.artist} </span> </> )} </div> )} </div> </div> </> ) } SpotifyNowPlaying.defaultProps = { apiUrl: "https://[yourdomain]/api/now-playing", layout: "compact", label: { text: "Now Playing", color: "#000000", font: {}, }, track: { titleColor: "#000000", artistColor: "#000000", separator: "—", font: {}, }, albumArt: { show: true, style: "square", }, content: { silenceText: "I'm currently enjoying the silence", loadingText: "Loading...", }, } addPropertyControls(SpotifyNowPlaying, { apiUrl: { type: ControlType.String, title: "API URL", }, layout: { type: ControlType.Enum, title: "Layout", options: ["compact", "spacious"], optionTitles: ["Compact", "Spacious"], }, albumArt: { type: ControlType.Object, title: "Album Art", controls: { show: { type: ControlType.Boolean, title: "Show", }, style: { type: ControlType.Enum, title: "Style", options: ["square", "cd"], optionTitles: ["Square", "Spinning CD"], hidden: (props: { show: boolean }) => !props.show, }, }, }, label: { type: ControlType.Object, title: "Label", controls: { text: { type: ControlType.String, title: "Text", }, color: { type: ControlType.Color, title: "Color", }, font: { type: ControlType.Font, title: "Font", controls: "extended", }, }, }, track: { type: ControlType.Object, title: "Track", controls: { titleColor: { type: ControlType.Color, title: "Title Color", }, artistColor: { type: ControlType.Color, title: "Artist Color", }, separator: { type: ControlType.String, title: "Separator", }, font: { type: ControlType.Font, title: "Font", controls: "extended", }, }, }, content: { type: ControlType.Object, title: "Content", controls: { silenceText: { type: ControlType.String, title: "Silence Text", }, loadingText: { type: ControlType.String, title: "Loading Text", }, }, }, })
import { addPropertyControls, ControlType } from "framer" import { useEffect, useState } from "react" interface NowPlayingData { albumImageUrl: string title: string artist: string songUrl: string loaded: boolean } type Layout = "compact" | "spacious" type AlbumStyle = "square" | "cd" interface Props { apiUrl: string layout: Layout label: { text: string color: string font: object } track: { titleColor: string artistColor: string separator: string font: object } albumArt: { show: boolean style: AlbumStyle } content: { silenceText: string loadingText: string } } const layoutConfig = { compact: { albumSize: 32, albumRadius: 4, labelSize: 10, titleSize: 12, artistSize: 12, gap: 8, innerGap: 5, labelGap: 2, }, spacious: { albumSize: 64, albumRadius: 10, labelSize: 12, titleSize: 20, artistSize: 20, gap: 16, innerGap: 6, labelGap: 4, }, } const ANIMATIONS = ` @keyframes marquee-scroll { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } } @keyframes cd-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes skeleton-shine { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } ` export default function SpotifyNowPlaying({ apiUrl, layout, label, track, albumArt, content, }: Props) { const [imgLoaded, setImgLoaded] = useState(false) const [data, setData] = useState<NowPlayingData>({ albumImageUrl: "", title: "", artist: "", songUrl: "", loaded: false, }) useEffect(() => { fetch(apiUrl) .then((res) => res.json()) .then((json) => { const hasTrackData = json.isPlaying && json.title && json.songUrl if (hasTrackData) { setData({ albumImageUrl: json.albumImageUrl, title: json.title, artist: json.artist, songUrl: json.songUrl, loaded: true, }) } else { setData({ albumImageUrl: "", title: content.silenceText, artist: "", songUrl: "", loaded: true, }) } }) .catch(() => { setData({ albumImageUrl: "", title: "Couldn't load now playing", artist: "", songUrl: "", loaded: true, }) }) }, [apiUrl, content.silenceText]) useEffect(() => { setImgLoaded(false) }, [data.albumImageUrl]) const { albumSize, albumRadius, labelSize, titleSize, artistSize, gap, innerGap, labelGap, } = layoutConfig[layout] ?? layoutConfig.compact const trackTitle = data.loaded ? data.title : content.loadingText const hasArtist = data.title.trim() !== "" && data.artist.trim() !== "" // Speed: roughly 1s per 4 chars, min 6s const marqueeDuration = Math.max( 6, (trackTitle.length + (data.artist?.length ?? 0)) * 0.25 ) const handleClick = () => { if (data.songUrl) window.open(data.songUrl, "_blank") } const isCD = albumArt.style === "cd" const imgBorderRadius = isCD ? "50%" : albumRadius // Show album art area only while loading (skeleton) or when a song is playing const showAlbumArea = albumArt.show && (!data.loaded || !!data.albumImageUrl) return ( <> <style>{ANIMATIONS}</style> <div onClick={handleClick} style={{ display: "flex", flexDirection: "row", alignItems: "center", gap, cursor: data.songUrl ? "pointer" : "default", width: "100%", height: "100%", overflow: "hidden", }} > {/* Album art */} {showAlbumArea && ( <div style={{ position: "relative", width: albumSize, height: albumSize, flexShrink: 0, borderRadius: imgBorderRadius, overflow: "hidden", }} > {/* Skeleton — only while fetching or image not yet loaded */} {(!data.loaded || !imgLoaded) && ( <div style={{ position: "absolute", inset: 0, background: "linear-gradient(90deg, #d0d0d0 25%, #e8e8e8 50%, #d0d0d0 75%)", backgroundSize: "200% 100%", animationName: "skeleton-shine", animationDuration: "1.5s", animationTimingFunction: "linear", animationIterationCount: "infinite", }} /> )} {/* Image */} {data.albumImageUrl && ( <img src={data.albumImageUrl} alt={`${data.title} album art`} onLoad={() => setImgLoaded(true)} style={{ width: albumSize, height: albumSize, objectFit: "cover", display: "block", opacity: imgLoaded ? 1 : 0, animationName: isCD ? "cd-spin" : "none", animationDuration: "5s", animationTimingFunction: "linear", animationIterationCount: "infinite", }} /> )} {/* CD center hole */} {isCD && imgLoaded && ( <div style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)", width: albumSize * 0.2, height: albumSize * 0.2, borderRadius: "50%", background: "white", boxShadow: "inset 0 0 3px rgba(0,0,0,0.25)", pointerEvents: "none", }} /> )} </div> )} {/* Text column */} <div style={{ display: "flex", flexDirection: "column", gap: labelGap, overflow: "hidden", flex: 1, minWidth: 0, }} > {/* Now playing label */} <span style={{ fontSize: labelSize, color: label.color, opacity: 1, whiteSpace: "nowrap", textTransform: "uppercase", letterSpacing: "0.06em", ...label.font, }} > {label.text} </span> {/* Compact: inline marquee (disabled for silence) */} {layout === "compact" ? ( <div style={{ overflow: "hidden", width: "100%" }}> <div style={{ display: "inline-flex", alignItems: "center", whiteSpace: "nowrap", animationName: data.songUrl ? "marquee-scroll" : "none", animationDuration: `${marqueeDuration}s`, animationTimingFunction: "linear", animationIterationCount: "infinite", }} > {/* Duplicate for seamless loop */} {(data.songUrl ? [0, 1] : [0]).map((i) => ( <span key={i} style={{ display: "inline-flex", alignItems: "center", gap: innerGap, paddingRight: 15, }} > <span style={{ fontSize: titleSize, color: track.titleColor, ...track.font, }} > {trackTitle} </span> {hasArtist && ( <> <span style={{ fontSize: titleSize, color: track.titleColor, ...track.font, }} > {track.separator} </span> <span style={{ fontSize: artistSize, color: track.artistColor, ...track.font, }} > {data.artist} </span> </> )} </span> ))} </div> </div> ) : ( /* Spacious: inline, no marquee */ <div style={{ display: "flex", flexDirection: "row", alignItems: "center", gap: innerGap, overflow: "hidden", }} > <span style={{ fontSize: titleSize, color: track.titleColor, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", flexShrink: 0, ...track.font, }} > {trackTitle} </span> {hasArtist && ( <> <span style={{ fontSize: titleSize, color: track.titleColor, flexShrink: 0, ...track.font, }} > {track.separator} </span> <span style={{ fontSize: artistSize, color: track.artistColor, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", ...track.font, }} > {data.artist} </span> </> )} </div> )} </div> </div> </> ) } SpotifyNowPlaying.defaultProps = { apiUrl: "https://[yourdomain]/api/now-playing", layout: "compact", label: { text: "Now Playing", color: "#000000", font: {}, }, track: { titleColor: "#000000", artistColor: "#000000", separator: "—", font: {}, }, albumArt: { show: true, style: "square", }, content: { silenceText: "I'm currently enjoying the silence", loadingText: "Loading...", }, } addPropertyControls(SpotifyNowPlaying, { apiUrl: { type: ControlType.String, title: "API URL", }, layout: { type: ControlType.Enum, title: "Layout", options: ["compact", "spacious"], optionTitles: ["Compact", "Spacious"], }, albumArt: { type: ControlType.Object, title: "Album Art", controls: { show: { type: ControlType.Boolean, title: "Show", }, style: { type: ControlType.Enum, title: "Style", options: ["square", "cd"], optionTitles: ["Square", "Spinning CD"], hidden: (props: { show: boolean }) => !props.show, }, }, }, label: { type: ControlType.Object, title: "Label", controls: { text: { type: ControlType.String, title: "Text", }, color: { type: ControlType.Color, title: "Color", }, font: { type: ControlType.Font, title: "Font", controls: "extended", }, }, }, track: { type: ControlType.Object, title: "Track", controls: { titleColor: { type: ControlType.Color, title: "Title Color", }, artistColor: { type: ControlType.Color, title: "Artist Color", }, separator: { type: ControlType.String, title: "Separator", }, font: { type: ControlType.Font, title: "Font", controls: "extended", }, }, }, content: { type: ControlType.Object, title: "Content", controls: { silenceText: { type: ControlType.String, title: "Silence Text", }, loadingText: { type: ControlType.String, title: "Loading Text", }, }, }, })
The serverless endpoint on Next JS.
// nowPlaying.ts import fetch from "node-fetch"; import { getAccessToken } from "./spotify"; const NOW_PLAYING_URL = `https://api.spotify.com/v1/me/player/currently-playing`; const getNowPlaying = async (): Promise<any> => { const accessTokenData = await getAccessToken(); const accessToken = accessTokenData.access_token; if (!accessToken) { throw new Error("Failed to get access token"); } const response = await fetch(NOW_PLAYING_URL, { headers: { Authorization: `Bearer ${accessToken}`, }, }); if (!response.ok) { throw new Error("Failed to fetch now playing"); } return response.json(); }; export { getNowPlaying }; // spotify.ts import axios from "axios"; const CLIENT_ID = process.env.SPOTIFY_CLIENT_ID as string; const CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET as string; const REDIRECT_URI = process.env.SPOTIFY_REDIRECT_URI as string; const SPOTIFY_TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token"; const getSpotifyAuthUrl = (): string => { return `https://accounts.spotify.com/authorize?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${REDIRECT_URI}&scope=user-read-currently-playing`; }; const REFRESH_TOKEN = process.env.SPOTIFY_REFRESH_TOKEN as string; const getAccessToken = async (): Promise<any> => { const body = new URLSearchParams(); body.append("grant_type", "refresh_token"); body.append("refresh_token", REFRESH_TOKEN); try { const response = await axios.post(SPOTIFY_TOKEN_ENDPOINT, body, { headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${Buffer.from( `${CLIENT_ID}:${CLIENT_SECRET}` ).toString("base64")}`, }, }); return response.data; } catch (error) { console.error("Error getting access token:", error); throw error; } }; export { getSpotifyAuthUrl, getAccessToken };
// nowPlaying.ts import fetch from "node-fetch"; import { getAccessToken } from "./spotify"; const NOW_PLAYING_URL = `https://api.spotify.com/v1/me/player/currently-playing`; const getNowPlaying = async (): Promise<any> => { const accessTokenData = await getAccessToken(); const accessToken = accessTokenData.access_token; if (!accessToken) { throw new Error("Failed to get access token"); } const response = await fetch(NOW_PLAYING_URL, { headers: { Authorization: `Bearer ${accessToken}`, }, }); if (!response.ok) { throw new Error("Failed to fetch now playing"); } return response.json(); }; export { getNowPlaying }; // spotify.ts import axios from "axios"; const CLIENT_ID = process.env.SPOTIFY_CLIENT_ID as string; const CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET as string; const REDIRECT_URI = process.env.SPOTIFY_REDIRECT_URI as string; const SPOTIFY_TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token"; const getSpotifyAuthUrl = (): string => { return `https://accounts.spotify.com/authorize?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${REDIRECT_URI}&scope=user-read-currently-playing`; }; const REFRESH_TOKEN = process.env.SPOTIFY_REFRESH_TOKEN as string; const getAccessToken = async (): Promise<any> => { const body = new URLSearchParams(); body.append("grant_type", "refresh_token"); body.append("refresh_token", REFRESH_TOKEN); try { const response = await axios.post(SPOTIFY_TOKEN_ENDPOINT, body, { headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${Buffer.from( `${CLIENT_ID}:${CLIENT_SECRET}` ).toString("base64")}`, }, }); return response.data; } catch (error) { console.error("Error getting access token:", error); throw error; } }; export { getSpotifyAuthUrl, getAccessToken };
// nowPlaying.ts import fetch from "node-fetch"; import { getAccessToken } from "./spotify"; const NOW_PLAYING_URL = `https://api.spotify.com/v1/me/player/currently-playing`; const getNowPlaying = async (): Promise<any> => { const accessTokenData = await getAccessToken(); const accessToken = accessTokenData.access_token; if (!accessToken) { throw new Error("Failed to get access token"); } const response = await fetch(NOW_PLAYING_URL, { headers: { Authorization: `Bearer ${accessToken}`, }, }); if (!response.ok) { throw new Error("Failed to fetch now playing"); } return response.json(); }; export { getNowPlaying }; // spotify.ts import axios from "axios"; const CLIENT_ID = process.env.SPOTIFY_CLIENT_ID as string; const CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET as string; const REDIRECT_URI = process.env.SPOTIFY_REDIRECT_URI as string; const SPOTIFY_TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token"; const getSpotifyAuthUrl = (): string => { return `https://accounts.spotify.com/authorize?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${REDIRECT_URI}&scope=user-read-currently-playing`; }; const REFRESH_TOKEN = process.env.SPOTIFY_REFRESH_TOKEN as string; const getAccessToken = async (): Promise<any> => { const body = new URLSearchParams(); body.append("grant_type", "refresh_token"); body.append("refresh_token", REFRESH_TOKEN); try { const response = await axios.post(SPOTIFY_TOKEN_ENDPOINT, body, { headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${Buffer.from( `${CLIENT_ID}:${CLIENT_SECRET}` ).toString("base64")}`, }, }); return response.data; } catch (error) { console.error("Error getting access token:", error); throw error; } }; export { getSpotifyAuthUrl, getAccessToken };
I’m excited to explore more ways to make my personal website even more personal by connecting it to APIs so it feels dynamic. Next, I want to do the same for other products I use daily, like Claude Code stats.
What would you bring into your personal website?
© 2026.
© 2026.

Get in touch