How to build a dynamic music portfolio site with SoundCloud and Webflow CMS

Store tracks in a Webflow CMS collection and render a SoundCloud player for every item using keyless oEmbed and the Widget API. No API key needed.

How to build a dynamic music portfolio site with SoundCloud and Webflow CMS

Ismail Ajagbe
Technical Author
View author profile
Ismail Ajagbe
Technical Author
View author profile
Table of contents

SoundCloud reopened its API, but you don't need a key to build a portfolio. Store tracks in Webflow CMS and render a player for every item.

Every musician who runs a site hits the same wall. You release a track, open the site, paste another embed by hand, and republish. The portfolio most artists actually want updates itself: a new release shows up the moment it lands in a list, with no manual embedding.

SoundCloud makes that reasonable again. Its public application registration was closed for years, and in 2026 the company moved to a fully self-service model where a Client ID and Client Secret now sit behind an Artist Pro subscription. For a portfolio, the good news is that you skip that path entirely. The oEmbed endpoint and the HTML5 player widget both work with no key at all.

This guide stores each track in a Webflow CMS collection and renders a SoundCloud player for every item from a Webflow Cloud app. I lean on oEmbed for the markup and the Widget API for playback, so nothing here depends on the paid API tier.

What do you need to build a SoundCloud music portfolio site in Webflow?

You need a Webflow Cloud project, a CMS collection to hold your tracks, and the public SoundCloud URLs for the audio you want to feature. No SoundCloud API key is part of this list.

Here is everything to have in place before the first line of code:

  • Webflow Cloud project: A Next.js app connected to your Webflow site, running locally or already deployed.
  • Tracks CMS collection: A collection with a plain-text field for each track's public SoundCloud URL.
  • Public SoundCloud URLs: The shareable link for every track, set, or playlist you want on the page.
  • Webflow site API token: A token with CMS read access so the app can list your collection items.
  • Node.js 22 or newer: The runtime for local development and the Webflow CLI.

With the collection and token ready, the build moves from Webflow to the player itself.

6 steps to build a SoundCloud music portfolio site in Webflow

The build has two halves. Webflow CMS holds the tracks and the app reads them, then SoundCloud turns each stored URL into a player that visitors can actually hear.

The first two steps set up the data, the middle steps generate the players, and the last step layers custom playback state over them.

Everything stays server-side until the finished player reaches the browser, which keeps your Webflow token and any future SoundCloud credentials off the client.

1. Create the Tracks collection in Webflow CMS

Open the Webflow Designer and add a new CMS collection called Tracks. The field that matters is a plain-text field for the track's public SoundCloud URL, so name it something predictable like SoundCloud URL. Webflow turns that label into the slug soundcloud-url, which is the key you read from the Data API later. Add whatever else your layout needs: an artist name, a release date, a short description, or a cover image. Keeping the SoundCloud URL as plain text rather than a link field avoids Webflow wrapping the value in extra markup. Populate a few items with real track links so you have something to render while you build. Once the collection holds items, the app has a source to read from.

2. Generate a Webflow site API token with CMS read access

Your Webflow Cloud app reads the collection through the Webflow Data API, and that call needs a site API token. In your site settings, open Apps and integrations, then API access, and generate a new token. Give it CMS read access so it can list collection items. The token is a secret, so it belongs in an environment variable rather than in your code.

Add it, along with the collection ID, to your local .env.local file and to the Webflow Cloud environment variables:

WEBFLOW_SITE_TOKEN=your_site_api_token
TRACKS_COLLECTION_ID=your_collection_id

The collection ID appears in the Designer under the collection's settings. On Webflow Cloud these values are read with process.env at request time, so the app picks up the deployed values without importing any config file. With the token stored, the app can fetch tracks.

3. Read the Tracks collection in your Webflow Cloud app

Add a server-side helper that lists the collection items through the Data API. Because it runs on the server, the token never reaches the browser, and a short revalidate window lets new releases appear without a redeploy.

This helper calls the endpoint and maps each item down to the fields the page needs:

// lib/tracks.ts
// Reads the Tracks collection from the Webflow Data API on the server.
const WEBFLOW_API = "https://api.webflow.com/v2";

export type Track = {
  id: string;
  name: string;
  soundcloudUrl: string;
};

export async function getTracks(): Promise<Track[]> {
  const res = await fetch(
    // The /items/live endpoint returns only published items, so drafts and
    // archived tracks never render a player.
    `${WEBFLOW_API}/collections/${process.env.TRACKS_COLLECTION_ID}/items/live?limit=100`,
    {
      headers: { Authorization: `Bearer ${process.env.WEBFLOW_SITE_TOKEN}` },
      // Refresh periodically so new releases appear without a redeploy.
      next: { revalidate: 300 },
    }
  );

  if (!res.ok) {
    throw new Error(`Webflow CMS request failed: ${res.status}`);
  }

  const { items } = await res.json();
  return items.map((item: any) => ({
    id: item.id,
    name: item.fieldData.name,
    soundcloudUrl: item.fieldData["soundcloud-url"],
  }));
}

The soundcloud-url key matches the field slug Webflow generated in step one. Calling the items/live endpoint returns only published items, so a track you are still drafting never reaches the page. Every item now arrives as a small Track object carrying an id, a name, and the SoundCloud URL ready to hand to the player. If the request fails, the thrown error surfaces in your Webflow Cloud logs instead of rendering a broken page.

4. Turn each track URL into a player with SoundCloud oEmbed

SoundCloud publishes an oEmbed endpoint that returns ready-made player markup for any public track, set, or user URL. It answers a plain fetch and needs no key, which is why it fits a portfolio so well. It follows the shared oEmbed standard.

The helper below asks the endpoint for JSON and returns the iframe markup it sends back:

// lib/oembed.ts
// Ask SoundCloud's oEmbed endpoint for the player markup.
// No API key is required for this endpoint.
export async function getPlayerHtml(trackUrl: string): Promise<string> {
  const endpoint = new URL("https://soundcloud.com/oembed");
  endpoint.searchParams.set("format", "json");
  endpoint.searchParams.set("url", trackUrl);
  endpoint.searchParams.set("maxheight", "166");
  endpoint.searchParams.set("show_comments", "false");
  endpoint.searchParams.set("color", "ff5500");

  const res = await fetch(endpoint, { next: { revalidate: 86400 } });
  if (!res.ok) return "";

  const data = await res.json();
  // data.html is an <iframe> pointing at w.soundcloud.com/player.
  return data.html as string;
}

The endpoint reads the url parameter and answers with an html string containing an iframe that points at w.soundcloud.com/player. The maxheight parameter is reflected in that embed, keeping the compact 166 pixel player. The color and show_comments parameters are part of the oEmbed spec, but SoundCloud does not always echo them into the returned iframe src, so for reliable control over the play-button color or comment display, append the equivalent Widget API parameters to the player URL yourself. Caching the response for a day avoids calling the endpoint on every render. Each track now resolves to a block of player HTML.

5. Render the players from your CMS list

A Server Component ties the two helpers together. It reads the tracks, resolves each one to player markup in parallel, and prints a list. Because the oEmbed html is trusted markup returned by SoundCloud, it goes in through dangerouslySetInnerHTML.

The page component maps every track to its heading and player:

// app/music/page.tsx
import { getTracks } from "@/lib/tracks";
import { getPlayerHtml } from "@/lib/oembed";

export default async function MusicPage() {
  const tracks = await getTracks();
  const players = await Promise.all(
    tracks.map(async (track) => ({
      ...track,
      html: await getPlayerHtml(track.soundcloudUrl),
    }))
  );

  return (
    <ul className="track-list">
      {players.map((track) => (
        <li key={track.id}>
          <h2>{track.name}</h2>
          <div dangerouslySetInnerHTML={{ __html: track.html }} />
        </li>
      ))}
    </ul>
  );
}

Running Promise.all resolves all the oEmbed requests together rather than one after another, so a page of a dozen tracks avoids a dozen sequential round trips. The class name on the list, track-list, is the hook the playback controller uses in the next step. Visit the route and every CMS track renders as its own SoundCloud player.

6. Reflect the active track with the Widget API

Separate SoundCloud players already coordinate on their own: single_active defaults to true, so starting one player pauses the others on the page. Where the Widget API earns its place is your own interface. Load its script, wrap each iframe in an SC.Widget instance, and listen for playback events to drive custom state.

This Client Component marks whichever track is sounding so you can style it as playing:

// components/PlaybackController.tsx
"use client";
import { useEffect } from "react";

declare global {
  interface Window { SC?: any; }
}

export default function PlaybackController() {
  useEffect(() => {
    const script = document.createElement("script");
    script.src = "https://w.soundcloud.com/player/api.js";
    script.onload = () => {
      const items = Array.from(
        document.querySelectorAll<HTMLLIElement>(".track-list li")
      );

      items.forEach((item) => {
        const iframe = item.querySelector("iframe");
        if (!iframe) return;
        const widget = window.SC.Widget(iframe);

        // Separate players already pause each other because single_active
        // defaults to true. Here we mirror playback state in our own markup.
        widget.bind(window.SC.Widget.Events.PLAY, () => {
          items.forEach((el) => el.classList.remove("is-playing"));
          item.classList.add("is-playing");
        });
        const clear = () => item.classList.remove("is-playing");
        widget.bind(window.SC.Widget.Events.PAUSE, clear);
        widget.bind(window.SC.Widget.Events.FINISH, clear);
      });
    };
    document.body.appendChild(script);
  }, []);

  return null;
}

The script exposes SC.Widget on window, and SC.Widget(iframeElement) returns a controller with bind, play, and pause methods. Binding the PLAY, PAUSE, and FINISH events keeps an is-playing class in sync with the current track, which is something the built-in coordination cannot express in your markup. Drop the component once into the route that renders the list. If you later set single_active to false so players stack, that same PLAY handler is where you would re-add manual pausing.

What causes a SoundCloud music portfolio to fail? Tips to troubleshoot

Most problems here come from four places: a track URL the endpoint cannot resolve, player markup that renders as text, the Widget script running too early, or several players fighting for the speakers at once.

Each symptom below pairs the cause with the fix that clears it.

oEmbed returns nothing and the player is blank

When getPlayerHtml comes back empty, the player area renders as a gap on the page. This almost always traces to the track URL rather than to your code.

Cause: The url passed to the oEmbed endpoint points at a private track, a deleted upload, or a mistyped path, so SoundCloud has nothing to embed. A private track resolves for you while you are signed in but returns an error to an anonymous server request.

Fix: Confirm the stored value is the public share URL by opening it in an incognito window. Set the track's privacy to public in SoundCloud, and make sure the CMS field holds the full https URL with no trailing spaces. The helper builds the query with URL and searchParams, so encoding is handled for you and the raw URL is what to check.

The page prints raw iframe code instead of audio

Instead of a play button, the page shows the literal iframe tag as text on the screen.

Cause: The oEmbed html string reached the DOM as escaped text rather than parsed markup. In React this happens when the string renders as a normal child instead of through dangerouslySetInnerHTML. In the native Webflow Designer it happens when the value lands in a text element instead of an HTML embed.

Fix: In a Webflow Cloud app, inject the markup with the dangerouslySetInnerHTML prop shown in step five. If you build the list natively in the Designer instead, place an HTML Embed element inside the Collection List and reference the SoundCloud URL field there, because embed elements render markup while text elements escape it.

SC.Widget is undefined when the controller runs

The console reports that SC or SC.Widget is not a function, and none of the playback wiring takes effect.

Cause: The controller code ran before the api.js script finished loading, or it ran on the server during rendering where no window object exists. The SC global only appears after the script loads in the browser.

Fix: Attach the widget setup to the script's onload handler, as the step six component does, so it runs only once SC is defined. Keep the component marked with the use client directive so it never executes during server rendering. If you inject the script through a framework helper instead, wait for its ready callback before calling SC.Widget on any iframe.

Players keep playing over each other

A visitor starts one track, scrolls down, starts another, and both keep sounding at once instead of the first one stopping.

Cause: The single_active parameter has been set to false somewhere in the player URL. SoundCloud's documented default is true, and that default is precisely what makes multiple players on a page toggle each other off, so switching it off removes the built-in coordination you were relying on.

Fix: Remove single_active=false from the embed URL, or set it explicitly to true, so the default mutual pausing applies again. If you genuinely want players to run independently, keep single_active=false on purpose and use the Widget API controller from step six to track playback and pause tracks yourself on the PLAY event.

What you can build with SoundCloud and Webflow

The core loop is in place: tracks live in Webflow CMS, and every item renders a keyless SoundCloud player with coordinated playback. From here the same collection scales into a fuller portfolio.

Because the players come from CMS data, any field you add becomes a control. A genre or mood field turns into filter buttons over the list, a release-date field drives an ordered discography, and a featured toggle can pin one track to the top. Swapping a single track URL for a playlist or set URL in the same oEmbed call renders a multi-track widget without touching the rest of the code. The color parameter lets each player match your brand instead of the default orange.

Explore Webflow + SoundCloud for the connection options and embed patterns the integration supports.

For control beyond what the embed widgets offer, Webflow's developer docs cover reading and syncing your CMS content through the Data API.

Frequently asked questions

Do I need a SoundCloud API key to embed tracks?

No. The oEmbed endpoint and the HTML5 player widget both work with no key or account credential. The Client ID and Client Secret, which now require an Artist Pro subscription, are only for the full API, such as programmatic uploads or reading private data. A portfolio needs neither.

What is the difference between oEmbed and the Widget API?

oEmbed is a server call that returns the iframe markup for a track, so you generate players from stored URLs. The Widget API is browser JavaScript that controls players already on the page: play, pause, seek, and events. This guide uses oEmbed to build players and the Widget API to coordinate them.

Can I embed SoundCloud playlists and sets, not just single tracks?

Yes. The oEmbed endpoint accepts a track, set, or user URL, so passing a playlist link returns a multi-track widget in the same html field. No code change is needed beyond storing the playlist URL in your CMS field. Set players default to a taller 450 pixel layout unless you override maxheight.

Do the players work with server-side rendering on Webflow Cloud?

Yes. The oEmbed fetch and the CMS read both run on the server with plain fetch and no Node-only dependencies, so they work on the Workers runtime. Only the Widget API script runs in the browser. The SoundCloud developer portal documents both paths.


Last Updated
August 16, 2026
Category

Related articles

How to add authentication and payments to a Webflow Cloud app with Auth0 and Stripe
How to add authentication and payments to a Webflow Cloud app with Auth0 and Stripe

How to add authentication and payments to a Webflow Cloud app with Auth0 and Stripe

How to add authentication and payments to a Webflow Cloud app with Auth0 and Stripe

Guides
By
Ismail Ajagbe
,
,
Read article
How to add Crisp live chat to a Webflow Cloud app the right way
How to add Crisp live chat to a Webflow Cloud app the right way

How to add Crisp live chat to a Webflow Cloud app the right way

How to add Crisp live chat to a Webflow Cloud app the right way

Guides
By
Ismail Ajagbe
,
,
Read article
How to add Algolia search and Cloudinary media optimization to a Webflow Cloud app
How to add Algolia search and Cloudinary media optimization to a Webflow Cloud app

How to add Algolia search and Cloudinary media optimization to a Webflow Cloud app

How to add Algolia search and Cloudinary media optimization to a Webflow Cloud app

Guides
By
Ismail Ajagbe
,
,
Read article
How to wire Auth0 to a Webflow Cloud App for server-side session validation
How to wire Auth0 to a Webflow Cloud App for server-side session validation

How to wire Auth0 to a Webflow Cloud App for server-side session validation

How to wire Auth0 to a Webflow Cloud App for server-side session validation

Development
By
Colin Lateano
,
,
Read article

verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.