Adding a Spotify "Now Playing" widget to your Jekyll site

10 June 2020

I recently added a widget to my homepage that shares what I’m currently playing on Spotify. It looks like this:

I’m currently listening to Hearts On Fire - Cut Copy.

[The above is just a static example. But if I am listening to something right now, you can see it on my homepage.]

Adding this simple line was more complicated than I had first anticipated, given that this site is made using Jekyll - a static site generator. Also, I didn’t want to use any client-side code.

1. Getting your Spotify credentials

To interact with the Spotify API you will need to get yourself a client_id and client_secret.

First, create a new application through the developer dashboard. You’ll have to log in with your Spotify account to get here. Then click “My New App” and fill in the details.

Then click through to your application to get them:

2. Authorising yourself

The next step is to go through a one-time OAuth dance. This will give you both an access_token, lasting one hour, and a refresh_token, which will let you continously update your access_token once it expires.

There are 3 different flows for authorising yourself with Spotify, depending on your needs.

Long-story short for this widget: because you are accessing a specific user’s data (i.e. yours) you need to go through the Authorization Code Flow -- unfortunately, the longest one:

From Spotify’s docs. It’s a... complicated dance.

To kick things off you need to construct an authorisation URL to visit in your browser. This will let you log in and tell Spotify that you’re giving this newly-created application the thumbs up to access your user data. 👍

The endpoint you need for this widget is documented here. You need to include the user-read-currently-playing scope when constructing the URL.

In an irb console:

require 'faraday'

response = Faraday.get "", {
  client_id: "YOUR_CLIENT_ID", # replace
  scope: "user-read-currently-playing",
  response_type: "code",
  redirect_uri: "" # replace - see below

puts response.env.url # Our authorisation URL


Important -- Whilst the value of redirect_uri doesn’t matter in terms of functionality, be aware that you will be redirected to it with sensitive details appended. So ideally you should own the domain to avoid it appearing in someone else’s logs.

You must also add the exact same redirect_uri value to your application’s settings:

Spotify settings

If you now grab the authorisation URL and visit it in your browser, you are asked to log in to Spotify (if you aren’t already) and agree.

Spotify accept page

Then the redirect URI kicks in and takes you to

Copy this VERY_LONG_CODE from the URL. This is known as the authorization code.

Note -- this is not the access_token or refresh_token. You now need to request those using this code. The dance goes on... 💃

Back in irb again:

response = "", {
  grant_type: "authorization_code",
  code: 'VERY_LONG_CODE', # replace
  client_id: 'YOUR_CLIENT_ID', # replace
  client_secret: 'YOUR_CLIENT_SECRET', # replace
  # no actual redirection happens, but must match app's setting
  redirect_uri: ""

require 'json'

access_token = JSON.parse(response.body)["access_token"]

refresh_token = JSON.parse(response.body)["refresh_token"]

You finally have your access_token and refresh_token.

Thankfully that is a one-time manual job, and the fun can now begin.

3. Getting your current playing song

Now fire up a song on Spotify and hit the currently playing endpoint:

endpoint = ''

response = Faraday.get endpoint, {}, {
  'Authorization' => "Bearer #{access_token}"

data = JSON.parse(response.body)

artist = data['item']['artists'].first['name']
# => "David Bowie"

title = data['item']['name']
# => "Moonage Daydream - 2012 Remaster"

After parsing the response data you now have your current playing track name and artist. 😎

The remaining steps are specific to my Jekyll site.

4. Adding the widget

On my homepage I use a Jekyll include for this widget, which lives in _includes/now_playing.html:


{% include now_playing.html %}


This include is empty initially - but I have a separate template with liquid tags, which I will use to render content for it:

<a class="icon-link" href="{{ track_id }}"><i class="fab fa-spotify pulsing"></i></a> I'm <a href="/spotify-now-playing">currently listening</a> to <strong>{{ title }}</strong> - <strong>{{ artist }}</strong>.


On my blog’s server I have the following process running in the background:

JEKYLL_ENV=production jekyll build --watch --incremental > /dev/null 2>&1 &

This process watches for updates to my Jekyll project - meaning that every time I update _includes/now_playing.html, any page that use this include will be rebuilt (and only these pages, thanks to the --incremental flag).

Now all that remains is for me to update _includes/now_playing.html whenever I start or stop playing a song, and everything should Just Work.

5. Updating the widget

In a perfect world, every time I hit play/pause on Spotify it would trigger a webhook to my server, allowing me to update my widget only when I need to.

Sadly, webhooks for player events are not yet supported by the API [although they are apparently high priority].

Therefore, I have to poll for changes at a suitable time interval. I decided on 30 seconds.

Firstly, I tidied up the earlier code into a single Ruby file, with some additions:

require 'faraday'
require 'json'
require 'yaml'
require 'base64'
require 'liquid'

class NowPlayingUpdate
  CONFIG_PATH = "#{Dir.home}/.spotify_nowplaying.yml"

  attr_reader :config, :client_id, :client_secret, :access_token,
    :refresh_token, :response

  def initialize
    @config = YAML.load_file(CONFIG_PATH)

    @client_id = config["client_id"]
    @client_secret = config["client_secret"]
    @access_token = config["access_token"]
    @refresh_token = config["refresh_token"]

  def run
    @response = currently_playing_response

    if unauthorized_response?
      run # try again
    elsif track_playing?


  def unauthorized_response?
    response.status == 401

  def update_currently_playing_track
      title: current_title, artist: current_artist, track_id: current_track_id

  def update_no_track_playing

  def currently_playing_data

  def track_playing?
    !response.body.empty? &&
      currently_playing_data["is_playing"] && # false if paused
      currently_playing_data["currently_playing_type"] == TRACK_PLAYING_TYPE

  # allow multiple artists to be combined
  def current_artist
      collect { |artist| artist["name"] }.join(" & ")

  # throw away sub-strings containing 'Remastered'
  def current_title
    currently_playing_data["item"]["name"].split(" - ").reject do |sub_str|
    end.join(" - ")

  def current_track_id

  def refresh_access_token
    @access_token = JSON.parse(refresh_token_response.body)["access_token"]

  def currently_playing_response
      'Authorization' => "Bearer #{access_token}"

  def refresh_token_response AUTH_ENDPOINT, {
      grant_type: "refresh_token",
      refresh_token: refresh_token
    }, {
      'Authorization' => "Basic #{bearer}"

  def persist_access_token
    @config["access_token"] = access_token

  def write_config, 'w') { |file| file.write config.to_yaml }

  def bearer

class JekyllNowPlayingIncludeUpdate
  LIQUID_TEMPLATE_PATH = '../_includes/now_playing.html.template'
  INCLUDE_PATH = '../_includes/now_playing.html'

  def clear, 'w') { |file| file.write }

  def run(title:, artist:, track_id:), 'w') do |file|
      file.write liquid_template.render(
        'title' => title, 'artist' => artist, 'track_id' => track_id)


  def liquid_template

  def path_to(path)
    File.join(File.dirname(__FILE__), path)


A few notes about this:

client_id: MY_CLIENT_ID
client_secret: MY_CLIENT_SECRET
access_token: MY_ACCESS_TOKEN
refresh_token: MY_REFRESH_TOKEN


And finally, on the server we add a cron task to run this ruby file every 30 seconds:

# crontab -e
* * * * * ruby /PATH_TO_PROJECT/tasks/spotify_nowplaying.rb
* * * * * sleep 30; ruby /PATH_TO_PROJECT/tasks/spotify_nowplaying.rb

Cron’s minimum unit is one minute, hence the sleep 30 workaround.

And we’re done. All that for one line. Totally worth it, of course. 😅

Possible future improvements:

Any questions or suggestions? Please feel free to get in touch --