Vibe coding with AI course · Module 11: Spotify Clone

Search, Discovery, and Recommendations

4 min read
In this lesson4

Spotify's power lies in discovery - powerful search, personalized playlists (Discover Weekly, Release Radar), and recommendations.

Search System

interface SearchResults {
  tracks: Track[];
  albums: Album[];
  artists: Artist[];
  playlists: Playlist[];
}

interface SearchFilters {
  type?: 'track' | 'album' | 'artist' | 'playlist';
  genre?: string[];
  year?: { min?: number; max?: number };
  explicit?: boolean;
}

// Search API
async function search(
  query: string,
  filters?: SearchFilters
): Promise<SearchResults> {
  // Implementation...
}

Search Page

'use client';

import { useState, useEffect } from 'react';

export default function SearchPage() {
  const [query, setQuery] = useState('');
  const [activeTab, setActiveTab] = useState<'all' | 'tracks' | 'albums' | 'artists' | 'playlists'>('all');
  const [results, setResults] = useState<SearchResults | null>(null);

  useEffect(() => {
    if (query.length > 0) {
      // Debounced search
      const timeout = setTimeout(() => {
        performSearch(query);
      }, 300);

      return () => clearTimeout(timeout);
    }
  }, [query]);

  const performSearch = async (searchQuery: string) => {
    // Mock search results
    setResults({
      tracks: mockTracks.filter(t =>
        t.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
        t.artist.name.toLowerCase().includes(searchQuery.toLowerCase())
      ),
      albums: mockAlbums.filter(a =>
        a.title.toLowerCase().includes(searchQuery.toLowerCase())
      ),
      artists: mockArtists.filter(a =>
        a.name.toLowerCase().includes(searchQuery.toLowerCase())
      ),
      playlists: mockPlaylists.filter(p =>
        p.name.toLowerCase().includes(searchQuery.toLowerCase())
      )
    });
  };

  return (
    <div className="min-h-screen bg-black text-white p-8">
      {/* Search Input */}
      <div className="mb-6">
        <div className="relative max-w-md">
          <input
            type="text"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="What do you want to listen to?"
            className="w-full bg-gray-800 text-white px-12 py-3 rounded-full outline-none focus:ring-2 focus:ring-white"
            autoFocus
          />
          <span className="absolute left-4 top-1/2 transform -translate-y-1/2 text-xl">
            ⌕
          </span>
        </div>
      </div>

      {!query ? (
        // Browse All / Recent Searches
        <div>
          <h2 className="text-2xl font-bold mb-4">Browse All</h2>
          <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
            {['Sea Shanties', 'Battle Anthems', 'Treasure Hunting', 'Calm Seas', 'Epic Adventures', 'Ship Repairs'].map(genre => (
              <button
                key={genre}
                className="aspect-square bg-gradient-to-br from-purple-600 to-pink-600 rounded-lg p-4 font-bold text-lg hover:scale-105 transition"
              >
                {genre}
              </button>
            ))}
          </div>
        </div>
      ) : results && (
        <>
          {/* Tabs */}
          <div className="flex gap-4 mb-6 border-b border-gray-800">
            {[
              { key: 'all', label: 'All' },
              { key: 'tracks', label: 'Songs' },
              { key: 'albums', label: 'Albums' },
              { key: 'artists', label: 'Artists' },
              { key: 'playlists', label: 'Playlists' }
            ].map(tab => (
              <button
                key={tab.key}
                onClick={() => setActiveTab(tab.key as any)}
                className={`pb-2 px-4 font-semibold ${
                  activeTab === tab.key
                    ? 'border-b-2 border-green-500'
                    : 'text-gray-400'
                }`}
              >
                {tab.label}
              </button>
            ))}
          </div>

          {/* Results */}
          <div className="space-y-6">
            {(activeTab === 'all' || activeTab === 'tracks') && results.tracks.length > 0 && (
              <div>
                <h3 className="text-xl font-bold mb-4">Songs</h3>
                <div className="space-y-2">
                  {results.tracks.slice(0, activeTab === 'all' ? 4 : undefined).map(track => (
                    <div
                      key={track.id}
                      className="flex items-center gap-4 p-2 rounded hover:bg-gray-800 cursor-pointer"
                    >
                      <img
                        src={track.album.coverArt}
                        alt={track.title}
                        className="w-12 h-12 rounded"
                      />
                      <div className="flex-1">
                        <div className="font-semibold">{track.title}</div>
                        <div className="text-sm text-gray-400">
                          {track.artist.name}
                        </div>
                      </div>
                      <span className="text-sm text-gray-400">
                        {Math.floor(track.duration / 60)}:{(track.duration % 60).toString().padStart(2, '0')}
                      </span>
                    </div>
                  ))}
                </div>
              </div>
            )}

            {(activeTab === 'all' || activeTab === 'artists') && results.artists.length > 0 && (
              <div>
                <h3 className="text-xl font-bold mb-4">Artists</h3>
                <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
                  {results.artists.slice(0, activeTab === 'all' ? 4 : undefined).map(artist => (
                    <div
                      key={artist.id}
                      className="bg-gray-900 p-4 rounded hover:bg-gray-800 cursor-pointer"
                    >
                      <img
                        src={artist.avatar}
                        alt={artist.name}
                        className="w-full aspect-square rounded-full mb-4"
                      />
                      <div className="font-semibold">{artist.name}</div>
                      <div className="text-sm text-gray-400">Artist</div>
                    </div>
                  ))}
                </div>
              </div>
            )}
          </div>
        </>
      )}
    </div>
  );
}

Recommendations System

interface RecommendationEngine {
  // Based on listening history
  discoverWeekly: (userId: string) => Playlist;

  // New releases from followed artists
  releaseRadar: (userId: string) => Playlist;

  // Based on specific seed
  radioStation: (seed: Track | Artist | Genre) => Track[];

  // Similar to a track
  similarTracks: (trackId: string, limit?: number) => Track[];
}

// Discover Weekly - personalized playlist based on listening history
function generateDiscoverWeekly(
  listeningHistory: Track[],
  likedTracks: Track[],
  followedArtists: Artist[]
): Playlist {
  // Algorithm:
  // 1. Analyze user's taste (genres, artists, audio features)
  // 2. Find similar tracks user hasn't heard
  // 3. Weight by popularity and recency
  // 4. Limit to 30 tracks

  return {
    id: 'discover-weekly',
    name: 'Discover Weekly',
    description: 'Your weekly mixtape of fresh music',
    coverImage: '/discover-weekly.jpg',
    owner: { id: 'spotify', username: 'Spotify', avatar: '/spotify.png' },
    tracks: [...], // 30 recommended tracks
    followers: 0,
    isPublic: false,
    isCollaborative: false,
    totalDuration: 0,
    createdAt: new Date(),
    updatedAt: new Date()
  };
}

Summary

Search system - tracks, albums, artists, playlists Debounced search - 300ms delay for performance Search tabs - filter results by type Browse categories - genre cards Recent searches - history tracking Discover Weekly - personalized recommendations Release Radar - new music from followed artists Radio station - endless playback based on seed Similar tracks - recommendation algorithm

You now have a complete Spotify clone with player, playlists, search, and recommendations!

See you soon!

Code for this lesson: App.tsx
'use client';

import { useState } from 'react';

interface Track {
  id: string;
  title: string;
  artist: string;
  album: string;
  coverArt: string;
  duration: number;
  isLiked: boolean;
}

const mockTracks: Track[] = [
  {
    id: '1',
    title: "The Pirate's Anthem",
    artist: 'Captain Redbeard',
    album: 'Sea Adventures',
    coverArt: 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=200&h=200&fit=crop',
    duration: 240,
    isLiked: true
  },
  {
    id: '2',
    title: 'Sailing the Seven Seas',
    artist: 'Crew Council',
    album: 'Ocean Voyage',
    coverArt: 'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=200&h=200&fit=crop',
    duration: 195,
    isLiked: false
  },
  {
    id: '3',
    title: 'Treasure Hunt',
    artist: 'Blackbeard Band',
    album: 'Gold & Glory',
    coverArt: 'https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=200&h=200&fit=crop',
    duration: 210,
    isLiked: true
  }
];

const mockPlaylist = {
  name: 'Pirate Favorites',
  description: 'Best sea shanties and adventure songs',
  coverImage: 'https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=400&h=400&fit=crop',
  owner: {
    username: 'Captain Redbeard',
    avatar: 'https://i.pravatar.cc/150?img=12'
  },
  followers: 1247,
  tracks: mockTracks
};

export default function PlaylistPage() {
  const [tracks, setTracks] = useState(mockTracks);

  const formatDuration = (seconds: number) => {
    const mins = Math.floor(seconds / 60);
    const secs = Math.floor(seconds % 60);
    return `${mins}:${secs.toString().padStart(2, '0')}`;
  };

  const totalDuration = tracks.reduce((acc, t) => acc + t.duration, 0);
  const totalMinutes = Math.floor(totalDuration / 60);

  return (
    <div className="min-h-screen bg-gradient-to-b from-blue-900 to-black text-white">
      {/* Header */}
      <div className="bg-gradient-to-b from-blue-900 to-transparent p-8">
        <div className="flex items-end gap-6">
          <img
            src={mockPlaylist.coverImage}
            alt={mockPlaylist.name}
            className="w-60 h-60 rounded shadow-2xl"
          />

          <div className="flex-1">
            <p className="text-sm font-semibold uppercase">Playlist</p>
            <h1 className="text-5xl font-bold my-4">{mockPlaylist.name}</h1>
            <p className="text-gray-300 mb-4">{mockPlaylist.description}</p>

            <div className="flex items-center gap-2 text-sm">
              <img
                src={mockPlaylist.owner.avatar}
                alt={mockPlaylist.owner.username}
                className="w-6 h-6 rounded-full"
              />
              <span className="font-semibold">{mockPlaylist.owner.username}</span>
              <span>•</span>
              <span>{mockPlaylist.followers.toLocaleString()} followers</span>
              <span>•</span>
              <span>{tracks.length} songs, {totalMinutes} min</span>
            </div>
          </div>
        </div>
      </div>

      {/* Actions */}
      <div className="px-8 py-6 flex items-center gap-4">
        <button className="w-14 h-14 bg-green-500 rounded-full flex items-center justify-center hover:scale-105 transition">
          <span className="text-black text-2xl ml-1">▶</span>
        </button>
        <button className="text-sm font-semibold text-gray-400 hover:text-white">Like</button>
        <button className="text-3xl text-gray-400 hover:text-white">⋯</button>
      </div>

      {/* Track List */}
      <div className="px-8 pb-8">
        <div className="grid grid-cols-[16px_6fr_4fr_minmax(120px,1fr)] gap-4 px-4 py-2 border-b border-gray-700 text-sm text-gray-400 mb-2">
          <div>#</div>
          <div>Title</div>
          <div>Album</div>
          <div className="text-right">⏱</div>
        </div>

        <div>
          {tracks.map((track, index) => (
            <div
              key={track.id}
              className="grid grid-cols-[16px_6fr_4fr_minmax(120px,1fr)] gap-4 px-4 py-2 rounded hover:bg-white hover:bg-white/10 group"
            >
              <div className="flex items-center text-gray-400">
                <span className="group-hover:hidden">{index + 1}</span>
                <button className="hidden group-hover:block">▶</button>
              </div>

              <div className="flex items-center gap-3">
                <img src={track.coverArt} alt={track.title} className="w-10 h-10 rounded" />
                <div>
                  <div className="font-semibold">{track.title}</div>
                  <div className="text-sm text-gray-400">{track.artist}</div>
                </div>
              </div>

              <div className="flex items-center text-sm text-gray-400">
                {track.album}
              </div>

              <div className="flex items-center justify-end gap-4">
                <button className="opacity-0 group-hover:opacity-100">
                  {track.isLiked ? 'Liked' : 'Like'}
                </button>
                <span className="text-sm text-gray-400">
                  {formatDuration(track.duration)}
                </span>
                <button className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-white">
                  ⋯
                </button>
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Why is debouncing used in search?

  2. 2. What result types does SearchResults return?

These are 2 of 6 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Click in order

    Click the elements to build the search call:

  • Click in order

    Click the elements to build the delayed search:

  • Horizontal ordering

    Arrange the SearchPage tabs from left to right:

  • Code editor

    Search interface

  • Vertical ordering

    Order the steps of debounced search in useEffect:

  • Vertical ordering

    Order the steps of generating Discover Weekly:

  • Click in order

    Click the elements to clear the previous timer in cleanup:

  • Horizontal ordering

    Arrange the tokens to filter tracks by title on one line:

  • Vertical ordering

    Order the steps of how a Radio Station works (based on a seed):

  • Code editor

    Music discovery interface

Useful articles