Published on March 26, 2026

Music discovery apps let users search for songs, explore artists, and preview tracks before committing to a full listen. Building one from scratch teaches you how to work with real-world music data at scale.
In this tutorial, you will build a music discovery web app using React and MusicAPI. By the end, you will have a working application that searches across multiple streaming platforms, displays artist profiles, and plays 30-second track previews.
The finished app includes three core features:
Before you start, make sure you have:
Scaffold a new React project with Vite and install the dependencies you need:
npm create vite@latest music-discovery -- --template react
cd music-discovery
npm install axios
npm install -D tailwindcss @tailwindcss/vite
Configure Tailwind in your vite.config.js:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
})
Add your MusicAPI key to a .env file in the project root:
VITE_MUSICAPI_KEY=your_api_key_here
Create a dedicated API module to keep your MusicAPI calls organized. This module handles authentication and provides typed helper functions for common queries.
Create src/api/musicapi.js:
import axios from 'axios'
const client = axios.create({
baseURL: 'https://api.musicapi.com/v2',
headers: {
Authorization: `Bearer ${import.meta.env.VITE_MUSICAPI_KEY}`,
'Content-Type': 'application/json',
},
})
export async function searchTracks(query, limit = 20) {
const response = await client.get('/search', {
params: { q: query, type: 'track', limit },
})
return response.data.results
}
export async function getArtist(artistId, platform = 'spotify') {
const response = await client.get(`/artists/${artistId}`, {
params: { platform },
})
return response.data
}
export async function getArtistTopTracks(artistId, platform = 'spotify') {
const response = await client.get(`/artists/${artistId}/top-tracks`, {
params: { platform, limit: 10 },
})
return response.data.tracks
}
export async function getTrackPreviews(trackId) {
const response = await client.get(`/tracks/${trackId}/previews`)
return response.data
}
MusicAPI normalizes data from Spotify, Apple Music, Tidal, and other platforms into a single unified schema. This means you write one set of API calls and get results from every connected platform without handling each provider's unique response format.
The search component is the entry point for your app. It sends user queries to MusicAPI and renders results as a scrollable list of track cards.
Create src/components/SearchBar.jsx:
import { useState } from 'react'
import { searchTracks } from '../api/musicapi'
export default function SearchBar({ onResults, onLoading }) {
const [query, setQuery] = useState('')
async function handleSearch(e) {
e.preventDefault()
if (!query.trim()) return
onLoading(true)
try {
const results = await searchTracks(query)
onResults(results)
} catch (error) {
console.error('Search failed:', error.message)
onResults([])
} finally {
onLoading(false)
}
}
return (
<form onSubmit={handleSearch} className="flex gap-2 w-full max-w-2xl mx-auto">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for songs, artists, or albums..."
className="flex-1 px-4 py-3 rounded-lg border border-gray-300
focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<button
type="submit"
className="px-6 py-3 bg-indigo-600 text-white rounded-lg
hover:bg-indigo-700 transition-colors"
>
Search
</button>
</form>
)
}
Now create src/components/TrackList.jsx to display the search results:
import TrackCard from './TrackCard'
export default function TrackList({ tracks, onSelectArtist }) {
if (tracks.length === 0) {
return (
<p className="text-center text-gray-500 mt-8">
No tracks found. Try a different search term.
</p>
)
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-8">
{tracks.map((track) => (
<TrackCard
key={track.id}
track={track}
onSelectArtist={onSelectArtist}
/>
))}
</div>
)
}
Each track card displays the song title, artist name, album art, and a built-in audio player for the 30-second preview. MusicAPI returns preview URLs directly in the search response, so you do not need a separate API call.
Create src/components/TrackCard.jsx:
import { useRef, useState } from 'react'
export default function TrackCard({ track, onSelectArtist }) {
const audioRef = useRef(null)
const [isPlaying, setIsPlaying] = useState(false)
function togglePlay() {
if (!track.previewUrl) return
if (isPlaying) {
audioRef.current.pause()
} else {
audioRef.current.play()
}
setIsPlaying(!isPlaying)
}
return (
<div className="bg-white rounded-xl shadow-md overflow-hidden hover:shadow-lg transition-shadow">
<div className="relative">
<img
src={track.albumArt || '/placeholder-album.png'}
alt={`${track.title} album art`}
className="w-full h-48 object-cover"
/>
{track.previewUrl && (
<button
onClick={togglePlay}
className="absolute bottom-3 right-3 w-12 h-12 bg-indigo-600
rounded-full flex items-center justify-center text-white
hover:bg-indigo-700 transition-colors shadow-lg"
aria-label={isPlaying ? 'Pause preview' : 'Play preview'}
>
{isPlaying ? '⏸' : '▶'}
</button>
)}
</div>
<div className="p-4">
<h3 className="font-semibold text-lg truncate">{track.title}</h3>
<button
onClick={() => onSelectArtist(track.artistId)}
className="text-indigo-600 hover:underline text-sm"
>
{track.artistName}
</button>
<p className="text-gray-500 text-sm mt-1">{track.albumName}</p>
<div className="flex items-center gap-2 mt-2">
{track.platforms?.map((platform) => (
<span
key={platform}
className="text-xs bg-gray-100 px-2 py-1 rounded-full text-gray-600"
>
{platform}
</span>
))}
</div>
</div>
{track.previewUrl && (
<audio
ref={audioRef}
src={track.previewUrl}
onEnded={() => setIsPlaying(false)}
/>
)}
</div>
)
}
Notice the platforms array in the track data. MusicAPI tells you which streaming services carry each track, so your app can show users exactly where they can listen to the full song.
When a user clicks an artist name, the app fetches detailed artist information and their top tracks. MusicAPI aggregates data from all connected platforms, giving you richer artist profiles than any single source provides.
Create src/components/ArtistProfile.jsx:
import { useState, useEffect } from 'react'
import { getArtist, getArtistTopTracks } from '../api/musicapi'
export default function ArtistProfile({ artistId, onBack }) {
const [artist, setArtist] = useState(null)
const [topTracks, setTopTracks] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchArtistData() {
setLoading(true)
try {
const [artistData, tracks] = await Promise.all([
getArtist(artistId),
getArtistTopTracks(artistId),
])
setArtist(artistData)
setTopTracks(tracks)
} catch (error) {
console.error('Failed to load artist:', error.message)
} finally {
setLoading(false)
}
}
fetchArtistData()
}, [artistId])
if (loading) {
return <div className="text-center py-12">Loading artist info...</div>
}
if (!artist) {
return <div className="text-center py-12">Artist not found.</div>
}
return (
<div className="max-w-4xl mx-auto">
<button
onClick={onBack}
className="text-indigo-600 hover:underline mb-6 inline-block"
>
← Back to search
</button>
<div className="flex flex-col md:flex-row gap-8 items-start">
<img
src={artist.imageUrl}
alt={artist.name}
className="w-64 h-64 rounded-2xl object-cover shadow-lg"
/>
<div>
<h2 className="text-3xl font-bold">{artist.name}</h2>
<p className="text-gray-600 mt-2">
{artist.genres?.join(', ')}
</p>
<div className="flex gap-4 mt-4 text-sm text-gray-500">
<span>{artist.followers?.toLocaleString()} followers</span>
<span>{artist.monthlyListeners?.toLocaleString()} monthly listeners</span>
</div>
{artist.bio && (
<p className="mt-4 text-gray-700 leading-relaxed">
{artist.bio}
</p>
)}
</div>
</div>
<h3 className="text-xl font-semibold mt-10 mb-4">Top Tracks</h3>
<div className="space-y-3">
{topTracks.map((track, index) => (
<div
key={track.id}
className="flex items-center gap-4 p-3 rounded-lg hover:bg-gray-50"
>
<span className="text-gray-400 w-6 text-right">{index + 1}</span>
<img
src={track.albumArt}
alt={track.title}
className="w-12 h-12 rounded"
/>
<div className="flex-1">
<p className="font-medium">{track.title}</p>
<p className="text-sm text-gray-500">{track.albumName}</p>
</div>
<span className="text-sm text-gray-400">
{Math.floor(track.durationMs / 60000)}:
{String(Math.floor((track.durationMs % 60000) / 1000)).padStart(2, '0')}
</span>
</div>
))}
</div>
</div>
)
}
Now connect everything in your App.jsx. The app manages three states: search results, the currently selected artist, and a loading indicator.
Update src/App.jsx:
import { useState } from 'react'
import SearchBar from './components/SearchBar'
import TrackList from './components/TrackList'
import ArtistProfile from './components/ArtistProfile'
export default function App() {
const [tracks, setTracks] = useState([])
const [selectedArtist, setSelectedArtist] = useState(null)
const [loading, setLoading] = useState(false)
if (selectedArtist) {
return (
<div className="min-h-screen bg-gray-50 p-8">
<ArtistProfile
artistId={selectedArtist}
onBack={() => setSelectedArtist(null)}
/>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm">
<div className="max-w-6xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-center mb-6">
Music Discovery
</h1>
<SearchBar onResults={setTracks} onLoading={setLoading} />
</div>
</header>
<main className="max-w-6xl mx-auto px-4 py-8">
{loading ? (
<p className="text-center text-gray-500">Searching...</p>
) : (
<TrackList
tracks={tracks}
onSelectArtist={setSelectedArtist}
/>
)}
</main>
</div>
)
}
Run the dev server to see it in action:
npm run dev
Open http://localhost:5173 in your browser, type a search query, and you should see track cards populate with album art, artist names, and playable previews.
A common challenge when building music apps is handling data from multiple streaming platforms. Spotify, Apple Music, and Tidal each use their own artist IDs, track formats, and metadata schemas. Without a normalization layer, you end up writing and maintaining separate integrations for each provider.
MusicAPI solves this by providing a single API that returns normalized data across all major platforms. When you call /search, the results include matches from every connected service, each mapped to a consistent schema. One artistId works across platforms. One trackId resolves to the same song regardless of source.
This matters for music discovery apps because:
Production apps need robust error handling. Here is a custom hook that wraps MusicAPI calls with consistent loading and error state management:
Create src/hooks/useMusicApi.js:
import { useState, useCallback } from 'react'
export default function useMusicApi(apiFunction) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const execute = useCallback(
async (...args) => {
setLoading(true)
setError(null)
try {
const result = await apiFunction(...args)
setData(result)
return result
} catch (err) {
const message =
err.response?.status === 429
? 'Rate limit reached. Wait a moment and try again.'
: err.response?.data?.message || 'Something went wrong.'
setError(message)
return null
} finally {
setLoading(false)
}
},
[apiFunction]
)
return { data, loading, error, execute }
}
MusicAPI uses standard HTTP status codes. A 429 means you hit the rate limit (configurable in your MusicAPI dashboard). A 401 means your API key is invalid or expired. Handle these explicitly so your users see helpful messages instead of generic errors.
You now have a working music discovery app that searches across platforms, shows artist profiles, and plays track previews. Here are some ways to extend it:
/albums/{id}/tracks endpoint to show full album track listings./playlists endpoints./recommendations endpoint returns personalized track suggestions based on seed artists or tracks.The complete source code for this tutorial is available on GitHub. Clone the repo, add your MusicAPI key, and start experimenting:
git clone https://github.com/musicapi/music-discovery-react-tutorial
cd music-discovery-react-tutorial
npm install
npm run dev
For the full MusicAPI documentation, including authentication guides, rate limit details, and endpoint references, visit musicapi.com/docs.