J.W.

J.W. van Bremen

Jan-Willem van Bremen

Rockstars Static

Rockstars Static thumbnail

This project was a learning experience and a good example of how to use Next.js. This project demonstrates how to use Next.js to render static web pages from an external data source. The application allows you to search for rockstars and their songs. The application is inspired by the Front-End Tech Case for my new position at Team Rockstars IT icon.


Technologies & Frameworks

  • Next.js Framework icon
  • Sass icon
  • Node.js icon
  • Git(hub) icon
  • Progressive Web Application icon
  • TailwindCSS icon
  • Netlify icon
  • Vercel icon

Summary

This project is a static remake of the Front-End Tech Case for my new position at Team Rockstars IT. The application is built using Next.js and TailwindCSS with JIT (Just In Time) mode. The application allows you to search for rockstars and their songs and browse songs per genre. Thanks to its static nature the application is extremely fast and easy to deploy using different hosting services like Vercel and Netlify. All pages are rendered at build time, served from a global CDN and cached using a service worker also supporting Progressive Web Application functionalities like caching and offline fallback support. I've had varying experience with different services regarding stability and built times. Vercel seems to be the fastest and most reliably for Next.js based applications by far.

The data source is a local JSON file served using the json-server package during the build. All pages are then statically generated using the Next.js framework. The application utilizes static generation and static generation with dynamic routes. The application is also optimized for mobile devices and is a fully featured Progressive Web Application.


Screens


Techniques & Libraries

Lighthouse Audit Score icon

Lighthouse score


Code Snippets

The following are some code snippets of pieces of code I'm proud of from this project. The snippets demonstrate clean, concise and powerful code following established best practices. (Code has been compacted)

Artists pages with Dynamic routing
This is the /artists/[artistName].js file. It is a dynamic route that is used to render the detail page for each artist. All artist pages are rendered using the Next.js framework with data provided by the local JSON server ran at build-time.

export async function getStaticProps({ params }) {
    const songs = await MusicService.getSongsByArtistName(encodeURIComponent(params.name))

    return {
        props: {
            songs
        }
    }
}

export async function getStaticPaths() {
    const artists = await MusicService.getArtists()

    const paths = artists.map(artist => {
        return {
            params: {
                name: artist.name
            }
        }
    })

    return {
        paths,
        fallback: false
    }
}

export default function artist({ songs }) {
    const pageSize = 25
    const router = useRouter()
    const [filteredSongs, setFilteredSongs] = useState(songs)
    const [page, setPage] = useState(1)

    const albums = songs?.map(song => song.album).filter((album, index, self) => self.indexOf(album) === index)
    const oldest = songs?.length ? songs?.reduce((a, b) => a.year < b.year ? a : b) : ''
    const newest = songs?.length ? songs?.reduce((a, b) => a.year > b.year ? a : b) : ''

    const filterSongs = (e) => {
        triggerLoader(router)
        setPage(1)
        setFilteredSongs(songs?.filter(song => {
            return Object.values({...song, spotifyId: ''}).some(value => {
                return value?.toString().toLowerCase().includes(e.target.value.toLowerCase())
        })}))
    }

    return (
        <div id="artist" className="flex flex-wrap justify-between gap-2">
            <div className="flex justify-between flex-wrap gap-4 mb-4 w-full">
                <h1>Artist: "{router.query.name}"</h1>
                <input className="p-2 text-rockstar-grey w-full mobile:w-auto" placeholder="Search songs! 🎵" onChange={e => filterSongs(e)}/>
                <span className="text-xl w-full -mb-4">{oldest.year} - {newest.year}</span>
                <span className="text-xl w-full -mb-4">{albums.length} Album<SOrNot arrayLength={albums.length}/></span>
                <h2 className="w-full -mb-4">{filteredSongs.length} Song<SOrNot arrayLength={filteredSongs.length} withColon /></h2>
            </div>
            {filteredSongs.slice(0, page * pageSize).length ? filteredSongs.slice(0, page * pageSize).map(song =>
                <SongCard key={song.id} song={song} showGenre/>
            ) : <h3>No results...</h3>}
            {filteredSongs.length > 50 && <ScrollToTopButton/>}
            {!(filteredSongs.slice(0, page * pageSize).length === filteredSongs.length) &&
            <LoadMoreButton fullWidth loadMore={() => { triggerLoader(router); setPage(page + 1) }}/>}
        </div>
    )
}

All Songs page
This is the all songs page. It is a static route that is used to render the page that lists all songs. It features advanced filtering on each property of a song and rudimentary sorting. Song elements are dynamically rendered using the React.js framework. It also demonstrates how to treat large amounts of data using JSON compression.

export async function getStaticProps() {
    let songs = await MusicService.getSongs()

    songs = songs.map(song => { // Trim unneeded properties from songs
        const { id, bpm, duration, shortname, ...trimmedSongs } = song
        return trimmedSongs
    })

    songs = compress(songs)

    return {
        props: {
            songs
        }
    }
}

export default function Songs({songs}) {
    const pageSize = 50
    const router = useRouter()
    songs = decompress(songs)
    const [filteredSongs, setFilteredSongs] = useState(songs)
    const [page, setPage] = useState(1)

    const filterSongs = (e) => {
        triggerLoader(router)
        setPage(1)
        setFilteredSongs(songs?.filter(song => {
            return Object.values({...song, spotifyId: ''}).some(value => {
                return value?.toString().toLowerCase().includes(e.target.value.toLowerCase())
            })
        }))
    }

    return (
        <div id="songs" className="flex flex-wrap justify-between gap-2">
            <div className="flex justify-between flex-wrap gap-4 mb-4 w-full">
                <div className="flex items-center gap-4 w-full mobile:w-auto justify-between mobile:justify-start">
                    <h1>All Songs</h1>
                    <button className="button !p-2 shadow-3xl !w-auto" onClick={() => setFilteredSongs([...filteredSongs].reverse())}>Sort ⇕</button>
                </div>
                <input className="p-2 text-rockstar-grey  w-full mobile:w-auto" placeholder="Search songs! 🎵" onChange={e => filterSongs(e)}/>
            </div>

            {filteredSongs.slice(0, page * pageSize).length ? filteredSongs.slice(0, page * pageSize).map(song =>
                <SongCard showArtist showGenre key={`${song.name} ${song.artist}`} song={song}/>
            ) : <h3>No results...</h3>}
            {filteredSongs.length >= 50 && <ScrollToTopButton/>}
            {!(filteredSongs.slice(0, page * pageSize).length === filteredSongs.length) &&
            <LoadMoreButton fullWidth loadMore={() => { triggerLoader(router); setPage(page + 1) }}/>}
        </div>
    )
}

Check out the project