How I Use Astro Actions, Environments, and Server Islands to show what I'm currently listening to on Spotify

This website’s homepage has a section where I show what I’m currently listening to on Spotify. Here’s how I built it using Astro Actions and Server Islands (Needs Astro 4.12 or more).

This post focuses on working with the Spotify API and Astro functionality. There are many blog posts on how to create a new Spotify app on the Spotify developer portal, so I will skip this. For this post, I assume you already have a Spotify client ID, client secret, and refresh token.

1. Adding Environment Variables

First, add your SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, and SPOTIFY_REFRESH_TOKEN to your env file. (Note: I’m using the Astro Cloudflare adapter, which uses .dev.vars for env variables, node, for example, uses the .env file. Check which adapter you are using.)

SPOTIFY_CLIENT_ID="<value>"
SPOTIFY_CLIENT_SECRET="<value>"
SPOTIFY_REFRESH_TOKEN="<value>"

2. Enable Experimental Features and Add Env Schema

Enable experimental actions, server islands, and env.

import cloudflare from "@astrojs/cloudflare";
import { defineConfig, envField } from "astro/config";

export default defineConfig({
  adapter: cloudflare({
    imageService: "passthrough",
    platformProxy: {
      enabled: true,
    },
  }),
  experimental: {
    // enables actions, server islands and env
    actions: true,
    serverIslands: true,
    env: {
      // write the env schema according to our env file
      schema: {
        SPOTIFY_CLIENT_ID: envField.string({
          context: "server",
          access: "secret",
          default: "",
        }),
        SPOTIFY_CLIENT_SECRET: envField.string({
          context: "server",
          access: "secret",
          default: "",
        }),
        SPOTIFY_REFRESH_TOKEN: envField.string({
          context: "server",
          access: "secret",
          default: "",
        }),
      },
    },
  },
  // must be either hybrid or server
  output: "hybrid",
  vite: {
    ssr: {
      // this is necessary to cloudflare's runtime for node compatibility, only needed if using the cloudflare adapter.
      external: ["node:buffer", "node:async_hooks"],
    },
  },
  // ...
});

3. Create Action for Fetching Spotify Data

Create a src/actions/index.ts to define an action for fetching currently playing data from Spotify.

// src/actions/index.ts
import type { SpotifyCurrentlyPlayingResponse } from "@/types/spotify";
import { ActionError, defineAction } from "astro:actions";
import {
  // you can now use the astro env to get type-safe and universal access to env variables across the project
  SPOTIFY_CLIENT_ID,
  SPOTIFY_CLIENT_SECRET,
  SPOTIFY_REFRESH_TOKEN,
} from "astro:env/server";
import { Buffer } from "node:buffer";

// the src/actions/index.ts file or src/actions.ts file must export an object named server.
export const server = {
  spotify: {
    playing: defineAction({
      // important to be "json" in this case because it's not being called from a form submit.
      accept: "json",
      handler: async (_) => {
        const credentials = `${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`;
        const credentialsBuffer = Buffer.from(credentials, "utf8");
        const authorization = credentialsBuffer.toString("base64");

        try {
          const accessTokenResponse = await fetch(
            "https://accounts.spotify.com/api/token",
            {
              method: "POST",
              headers: {
                Authorization: `Basic ${authorization}`,
                "Content-Type": "application/x-www-form-urlencoded",
              },
              body: new URLSearchParams({
                grant_type: "refresh_token",
                refresh_token: SPOTIFY_REFRESH_TOKEN,
                client_id: SPOTIFY_CLIENT_ID,
                client_secret: SPOTIFY_CLIENT_SECRET,
                scope: "user-read-currently-playing user-read-recently-played",
              }),
            }
          );

          const token = (await accessTokenResponse.json()) as {
            access_token: string;
          };

          const response = await fetch(
            "https://api.spotify.com/v1/me/player/currently-playing",
            {
              headers: {
                Authorization: `Bearer ${token.access_token}`,
              },
            }
          );

          if (response.status === 204) {
            return {
              message: "I'm not currently playing anything on Spotify",
            };
          }

          const data = await response.json<SpotifyCurrentlyPlayingResponse>();

          return {
            spotify: data,
          };
        } catch {
          throw new ActionError({
            code: "BAD_REQUEST",
            message: "Error fetching data from Spotify",
          });
        }
      },
    }),
  },
};

4. Call the Action from an Astro Component

Create an Astro component to display the Spotify data.

---
import { actions } from "astro:actions";

// call the action from the frontmatter of the component
const { data, error } = await actions.spotify.playing.safe();
---

{error && <p class="p-4">{error.message}</p>}
{data?.message && <p class="p-4">{data.message}</p>}
{
  data?.spotify && (
    <a
      href={data.spotify.item.external_urls.spotify}
      target="_blank"
      class="block p-4"
    >
      I'm currently listening to {data.spotify.item.name} by{" "}
      {data.spotify.item.artists[0].name} on Spotify.
    </a>
  )
}

5. Render the Component inside an Astro Page

Use the server:defer directive that comes with the server islands experimental feature.

---
import currentlyPlaying from "@/components/common/currently-listening.astro";
---

<!-- ... -->
<currentlyPlaying server:defer>
  <p class="p-4" slot="fallback">Loading Spotify data...</p>
</currentlyPlaying>
<!-- ... -->

With this, fetching the Spotify data does not block the rest of the page from rendering (which happens when Streaming HTMl) and instead defers the render asynchronously after the page has already rendered, showing a fallback while not resolved.

Conclusion

With Astro launching a lot of awesome features with amazing DX, I really liked this approach as I can easily write code in an organized way. I can put all logic that includes fetches, queries, mutations in the actions folder, and easily separate what should render on the client and what should render on the server.

Extra

I wrote a spotify.ts file under src/types/ that matches what the https://api.spotify.com/v1/me/player/currently-playing returns when successful.

// src/types/spotify.ts
export type SpotifyCurrentlyPlayingResponse = {
  timestamp: number;
  context: {
    external_urls: {
      spotify: string;
    };
    href: string;
    type: string;
    uri: string;
  };
  progress_ms: number;
  item: {
    album: {
      album_type: string;
      artists: any[];
      available_markets: any[];
      external_urls: any;
      href: string;
      id: string;
      images: any[];
      name: string;
      release_date: string;
      release_date_precision: string;
      total_tracks: number;
      type: string;
      uri: string;
    };
    artists: any[];
    available_markets: string[];
    disc_number: number;
    duration_ms: number;
    explicit: boolean;
    external_ids: {
      isrc: string;
    };
    external_urls: {
      spotify: string;
    };
    href: string;
    id: string;
    is_local: boolean;
    name: string;
    popularity: number;
    preview_url: string;
    track_number: number;
    type: string;
    uri: string;
  };
  currently_playing_type: string;
  actions: {
    disallows: {
      resuming: boolean;
    };
  };
  is_playing: boolean;
};

Note: the song below is just that sometimes I like to attach a song to each post that I think “matches” with it.