Published on

Next.js YouTube Video Gallery

Authors

What we are building

Inspired by my projects Awesome React and Awesome Next.js and Colby Fayock's Video Gallery project in 50 Project For React & The Static Web we are going to build a website highlighting the best YouTube Channels of Kitesurfers.

The short guide shows how to work with the YouTube API and teaches you some fundamental Next.js concepts like data fetching and dynamic routing.

Best Kitesurf YouTube Channels WebsiteHomepage with a curated list of some of the best Kitesurf YouTube channels.Channel page with list of videosChannel page with list of videos.Channel page with selected video playingChannel page with selected video playing inside app.

Initalize Next.js App

To get started with a fresh Next.js app already setup with Tailwind CSS (an extremely popular and also my preferred choice to style Next.js apps) we run:

npx create-next-app -e with-tailwindcss video-gallery
# or
yarn create next-app -e with-tailwindcss video-gallery

This installs all the necessary dependencies and prompts you to start with:

cd video-gallery
yarn dev

Now you already have a running Next.js application on http://localhost:3000.

 Setting up YouTube API

Create Google account and project

To use the YouTube API we need a Google account. If you don't have one yet, create an account first. Go to https://accounts.google.com and create a free account.

Then go to https://console.developers.google.com/projectcreate to create a new project.

Create project Video Gallery

Create API Key

Go to APIs & Services and select Credentials. Click + CREATE CREDENTIALS  and select API key.

Create API key

Copy the API key and save it for later.

Enable YouTube API

Next we need to enable the YouTube API for our user. Search for YouTube in the search box or go directly to the YouTube Data API v3 library page. Click the ENABLE button.

Enable YouTube API

Now we are ready to query the YouTube API.

Exploring YouTube API

First let’s get a feel for the API and which endpoints it provides. Open the YouTube API Reference docs.

We want to display the best channels from kitesurfers. On the homepage we will have a grid of the channels with the channel title and a thumbnail.

So let’s get this information. We need to query the Channels:list endpoint. We can either get the information by providing the id of the channel or the username with the query parameter forUsername.

Get the channel ID

If you search for a YouTube user and click on a profile you will get a channel URL with the username (e.g. https://www.youtube.com/user/Langeree).

To get the channel ID you first have to select a video and then click the profile image above the video description. Now you have the channel URL with the channel ID (e.g. https://www.youtube.com/channel/UC0TsOcFEW9FoTP6qdvZIJqA).

Query the API with the API Explorer

YouTube provides a handy API Explorer next to the API docs to quickly test out the API. Let’s enter our example channel ID UC0TsOcFEW9FoTP6qdvZIJqA in the id field and snippet in the part field and click EXECUTE. You may need to allow your Google account to access the API and should get a JSON response.

The fields we are interested in (title and thumbnails) are indeed in the snippet part.

Now let’s query the same information in our Next.js app.

Loading YouTube channel info

For loading anything from the YouTube API, we first need to setup our API key as an environment variable.

Environment Variables

Next.js has built-in support for loading environment variables from .env.local into process.env.

Create a .env.local file in the root directory:

YOUTUBE_API_KEY=Your YouTube API key

This loads process.env.YOUTUBE_API_KEY into the Node.js environment automatically allowing you to use them in Next.js data fetching methods and API routes.

Function getChannelInfo

Next we create a file lib/youtube.js for our YouTube specific functions. We start with the function getChannelInfo.

lib/youtube.js
const YOUTUBE_HOST = "https://youtube.googleapis.com";

export async function getChannelInfo(channelId) {
  try {
    const response = await fetch(
      `${YOUTUBE_HOST}/youtube/v3/channels?part=snippet,contentDetails&id=${channelId}&key=${process.env.YOUTUBE_API_KEY}`
    );

    const data = await response.json();

    return data;
  } catch (err) {
    console.log(err);
  }

  return null;
}

This function queries the YouTube API Channels endpoint and takes the channelId as a parameter.

Next.js data fetching

There are multiple possibilities with Next.js to load the data from YouTube. We could load it client side, but I prefer to load the data directly on the server, so there is no need to show any loading indicators.

We therefor change our index.js file to the following:

pages/index.js
import { getChannelInfo } from "lib/youtube";

export default function Home() {
  return <h1>Best Kitesurf YouTube Channels</h1>;
}

export async function getServerSideProps() {
  const channels = [
    "UC0TsOcFEW9FoTP6qdvZIJqA", // Kevin Langeree
    "UCmzxts0YGES5tN-oJ9abTQg", // Kai Lenny
    "UCueYbWdMJ-u7xb7gKE2B2Qw", // Ruben Lenten
    "UCpvzntBAE9Yq3NTOVOdEpqQ", // Aaron Hadlow
  ];

  const channelInfos = await Promise.all(
    channels.map((channelId) => getChannelInfo(channelId))
  );

  console.log(JSON.stringify(channelInfos, null, 2));

  return {
    props: {
      channelInfos,
    },
  };
}

We import our previously created function getChannelInfo and export the Next.js data fetching method getServerSideProps. getServerSideProps is called on each page request by Next.js and the returned data is passed as props to the page component.

We load the channel info of four Kitesurfer’s YouTube channels and pass it as the prop channelInfos to our component.

Displaying YouTube channels in grid

We want to display the best Kitesurf YouTube channels in a grid with a thumbnail and the title of the channel. We get this information from the passed channelInfos prop.

pages/index.js
import { getChannelInfo } from "lib/youtube";
import Link from "next/link";

export default function Home({ channelInfos }) {
  return (
    <div className="mx-auto max-w-6xl p-4 lg:p-8">
      <h1>Best Kitesurf YouTube Channels</h1>
      <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
        {channelInfos.map((channelInfo) => {
          const {
            id,
            snippet: { title, thumbnails },
          } = channelInfo.items[0];

          return (
            <Link key={id} href={`/channel/${id}`}>
              <a>
                <img src={thumbnails.medium?.url} />
                <div className="text-sm font-medium mt-2">
                  {title}
                </div>
              </a>
            </Link>
          );
        })}
      </div>
    </div>
  );
}

...

Tailwind CSS’s utility classes allow us to quickly have a good looking responsive grid.

Best Kitesurf YouTube Channels Homepage

Optimizing images with next/image

As the YouTube API also provides height and width for each thumbnail we can easily swap the img tag with the <Image /> component exported by next/image and get automatic image optimizations.

We change our code to the following:

pages/index.js
import Image from "next/image";

...

<Image
  src={thumbnails.medium?.url}
  height={thumbnails.medium?.height}
  width={thumbnails.medium?.width}
/>

...

To enable Image Optimization for images hosted on an external website we need to specify which domains are allowed to be optimized.

We create our next.config.js in our root folder if it doesn’t exist already and add two domains used by YouTube to host images.

next.config.js
module.exports = {
  images: {
    domains: ["yt3.ggpht.com", "i.ytimg.com"],
  },
};

Channel detail page

 Dynamic Routes

Next.js supports file based dynamic routes. We use these dynamic routes for our channel detail page.

Channels are available at e.g. /channel/UC0TsOcFEW9FoTP6qdvZIJqA . To support such URL’s we need to create a file channel/[id].js.

pages/channel/[id].js
export default function Channel({ id }) {
  return <h1>{id}</h1>;
}

export async function getServerSideProps({ query }) {
  const { id } = query;

  // TODO fetch videos

  return {
    props: {
      id,
    },
  };
}

The channel ID is passed to our page as query.id, because we named the parameter between the square brackets id.

Fetch all videos from channel

We want to display all videos from a channel. But how do we get them? In our getChannelInfo function we not only loaded the snippet, but also the contentDetails part of the channel. You might wonder why, because we haven’t used it so far. But on the contentDetails part there is a uploads playlist id which we can use to load all videos.

It’s usually (always?) the same as the channel id. But to be sure we get it from our channel info response.

getAllPlaylistItems function

To load videos from a playlist we use the PlaylistItems endpoint and need to pass it the playlistId. With the maxResults parameter we can load up to 50 videos. The default value is 5.

If we want to load more than 50 we need to use the parameter pageTokento query a specific page. In an API response, the nextPageToken and prevPageToken properties identify other pages that could be retrieved. So to load more we need to check if we get a nextPageToken back and if yes query the endpoint again and pass it to the parameter pageToken.

In our function getAllPlaylistItems below we have implemented just that.

lib/youtube.js
export async function getAllPlaylistItems(playlistId) {
  try {
    const playlistItemsUrl = `${YOUTUBE_HOST}/youtube/v3/playlistItems?part=snippet&maxResults=50&playlistId=${playlistId}&key=${process.env.YOUTUBE_API_KEY}`;
    let playlistItems = [];

    let response = await fetch(playlistItemsUrl);
    let data = await response.json();

    playlistItems = data.items;

    while (data.nextPageToken) {
      response = await fetch(
        `${playlistItemsUrl}&pageToken=${data.nextPageToken}`
      );

      data = await response.json();

      playlistItems = playlistItems.concat(data.items);
    }

    return playlistItems;
  } catch (err) {
    console.log(err);
  }

  return null;
}

Now let’s call this function in our channel detail page.

pages/channel/[id].js
export async function getServerSideProps({ query }) {
  const { id } = query;

  const info = await getChannelInfo(id);

  if (info.pageInfo.totalResults === 0) {
    return {
      notFound: true,
    };
  }

  const playlistId = info.items[0].contentDetails.relatedPlaylists.uploads;
  const title = info.items[0].snippet.title;
  const videos = await getAllPlaylistItems(playlistId);

  return {
    props: {
      title,
      videos,
    },
  };
}

We pass the channel ID to our function getChannelInfo and check if we get any information back. If not the channel ID doesn’t exist and we return a 404 not found page.

If it exists we get the channel title and the uploads playlist ID from the response and use the latter to get the channel’s videos with our function getAllPlaylistItems.

We both pass the title and the videos to our page component.

Displaying channel videos

Similar to our channel overview page we display the channel’s videos in a responsive grid.

Change our page to this:

pages/channel/[id].js
import { formatDistance } from "date-fns";
import { getChannelInfo, getAllPlaylistItems } from "lib/youtube";
import Image from "next/image";

export default function Channel({ title, videos }) {
  return (
    <div>
      <div className="mx-auto max-w-6xl p-4 lg:p-8">
        <h1 className="text-2xl sm:text-3xl lg:text-4xl my-4 font-bold">
          {title}
        </h1>
        <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
          {videos.map(
            ({
              snippet: {
                title,
                publishedAt,
                thumbnails,
                resourceId: { videoId },
              },
            }) => {
              return (
                <a
                  key={videoId}
                  href={`https://www.youtube.com/watch?v=${videoId}`}
                >
                  <Image
                    src={thumbnails.medium?.url}
                    height={thumbnails.medium?.height}
                    width={thumbnails.medium?.width}
                  />
                  <div className="flex flex-col justify-between mt-2">
                    <div className="text-sm font-medium">
                      {title}
                    </div>
                    <div className="text-gray-700 text-xs mt-2">
                      {formatDistance(new Date(publishedAt), new Date())}
                    </div>
                  </div>
                </a>
              );
            }
          )}
        </div>
      </div>
    </div>
  );
}

export async function getServerSideProps({ query }) { ... }

To format the publishedAt date we need to install date-fns a great JavaScript date utility library by running:

npm install date-fns
# or
yarn add date-fns

As on YouTube we display how long ago a video was published instead of the exact date.

By clicking a video we open the video on YouTube. In the next step we change our code to let users play the videos directly on our page.

Playing videos on page

Until now we have just displayed the video thumbnails. To embed YouTube videos we need to use iFrames and one way to make them responsive is with the @tailwindcss/aspect-ratio plugin.

Tailwind Aspect Ratio Plugin

Install it by running:

npm install @tailwindcss/aspect-ratio # or
yarn add @tailwindcss/aspect-ratio

Then add the plugin to your tailwind.config.js file:

tailwind.config.js
module.exports = {
  theme: {
    // ...
  },
  plugins: [
    require('@tailwindcss/aspect-ratio'),
    // ...
  ],
}

The plugin provides CSS classes aspect-w-16 and aspect-h-9 which we can use to make the YouTube embed responsive and the right aspect ratio.

<div className="aspect-w-16 aspect-h-9">
  <iframe
    src={`https://www.youtube.com/embed/${videoId}?autoplay=1`}
    frameBorder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
    allowFullScreen
  ></iframe>
</div>

Embed video

We display the video inline together with the title of the video like this:

pages/channel[id].js
import classNames from "classnames";
import { formatDistance } from "date-fns";
import { getChannelInfo, getAllPlaylistItems } from "lib/youtube";
import Image from "next/image";

export default function Channel({ title, videos }) {
  const currentVideoIndex = 0;
  return (
    <div>
      <div className="mx-auto max-w-6xl p-4 lg:p-8">
        <h1 className="text-2xl sm:text-3xl lg:text-4xl my-4 font-bold">
          {title}
        </h1>
        <div
          className={classNames("bg-gray-200 pt-20 p-12 mb-4 relative", {
            hidden: currentVideoIndex === null,
          })}
        >
          {currentVideoIndex !== null && (
            <>
              <div className="aspect-w-16 aspect-h-9">
                <iframe
                  src={`https://www.youtube.com/embed/${videos[currentVideoIndex].snippet.resourceId.videoId}?autoplay=1`}
                  frameBorder="0"
                  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
                  allowFullScreen
                ></iframe>
              </div>
              <h2 className="text-3xl font-medium my-6">
                {videos[currentVideoIndex].snippet.title}
              </h2>
            </>
          )}
        </div>
        <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">

...

For the moment we hardcoded displaying the first video with currentVideoIndex = 0.

Another handy library we are using is classnames to make it easier to add classes depending on a state or value. In our case we use it to hide the embedded video if our currentVideoIndex is null.

To install it run:

npm install classnames
#or
yarn add classnames

We get our videoId with videos[currentVideoIndex].snippet.resourceId.videoId and by adding autoplay=1 to the URL we directly start playing the YouTube video.

We also want to display the video description below the video and title. It would be nice if links in the description would be clickable and properly styled. We need to install two node modules to achieve that.

Converting plain-text URLs to HTML

To make URLs in the text clickable we use a node module called linkifyjs, a JavaScript plugin for finding URLs in plain-text and converting them to HTML links.

Run:

npm install linkifyjs
#or
yarn add linkifyjs

Styling URLs as links

To style URLs properly we need to install the @tailwindcss/typography plugin which provides prose classes to add sensible typography styles to any vanilla HTML.

Install it by running:

npm install @tailwindcss/typography
#or
yarn add @tailwindcss/typography

and add it to the tailwind.config.js:

tailwind.config.js
module.exports = {
  theme: {
    // ...
  },
  plugins: [
    require('@tailwindcss/typography'),
    // ...
  ],
}

Adding description

Now we are able to display the description with the following code snippet:

import linkifyHtml from "linkifyjs/html";
...

<p
  className="prose"
  dangerouslySetInnerHTML={{
    __html: linkifyHtml(
      videos[currentVideoIndex].snippet.description?.replace(/\n/g, " <br />")
    ),
  }}
></p>
...

Next let’s implement selecting videos by clicking the thumbnails.

Selecting videos

We start by creating a function selectVideoByIndex and change our variable currentVideoIndex to a useState hook.

const [currentVideoIndex, setCurrentVideoIndex] = useState(null);

function selectVideoByIndex(index) {
  if (index > videos.length - 1) {
    setCurrentVideoIndex(0);
  } else {
    if (index < 0) {
      setCurrentVideoIndex(videos.length - 1);
    } else {
      setCurrentVideoIndex(index);
    }
  }
}

The function does nothing more than setting our currentVideoIndex and if the index is too high it sets it to 0 and if it is negative it sets it to the latest. This allows us to then implement selecting the next or previous video.

We now change the thumbnail link to a button to call our function selectVideoByIndex.

<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
  {videos.map(
    (
      {
        snippet: {
          title,
          publishedAt,
          thumbnails,
          resourceId: { videoId },
        },
      },
      index
    ) => {
      return (
        <button
          key={videoId}
          className="text-left"
          onClick={() => selectVideoByIndex(index)}
        >
          <Image
            src={thumbnails.medium?.url}
            height={thumbnails.medium?.height}
            width={thumbnails.medium?.width}
          />
          <div className="flex flex-col justify-between mt-2">
            <div className="line-clamp-2 text-sm font-medium">{title}</div>
            <div className="text-gray-700 text-xs mt-2">
              {formatDistance(new Date(publishedAt), new Date())}
            </div>
          </div>
        </button>
      );
    }
  )}
</div>

If we click a video it’s now playing inline. But if we scroll down to a thumbnail below and click the video, the video starts playing, but we don’t see it, because we are not scrolling to top. Let’s fix that by adding window.scrollTo({ left: 0, top: 0, behavior: "smooth" }); to our selectVideoByIndex function to scroll to top of page whenever we click a video thumbnail.

The function selectVideoByIndex now looks like this:

function selectVideoByIndex(index) {
  if (index > videos.length - 1) {
    setCurrentVideoIndex(0);
  } else {
    if (index < 0) {
      setCurrentVideoIndex(videos.length - 1);
    } else {
      setCurrentVideoIndex(index);
    }
  }
  window.scrollTo({ left: 0, top: 0, behavior: "smooth" });
}

Play next and previous videos

We add an absolutely positioned small navigation for the controls to play previous, play next and close video. We get the icons from heroicons as they are beautifully designed and you can directly copy JSX. Our controls consist of the icons chevron-left, chevron-right and x.

Add the following code for our controls:

...

<div
  className={classNames("bg-gray-200 pt-20 p-12 mb-4 relative", {
    hidden: currentVideoIndex === null,
  })}
>
  <div className="absolute top-0 right-0">
    <button
      className="p-2 bg-gray-300 hover:bg-gray-400 focus:bg-gray-400"
      type="button"
      onClick={() => selectVideoByIndex(currentVideoIndex - 1)}
    >
      <svg
        xmlns="http://www.w3.org/2000/svg"
        className="h-5 w-5"
        fill="none"
        viewBox="0 0 24 24"
        stroke="currentColor"
      >
        <path
          strokeLinecap="round"
          strokeLinejoin="round"
          strokeWidth={2}
          d="M15 19l-7-7 7-7"
        />
      </svg>
    </button>
    <button
      className="p-2 bg-gray-300 hover:bg-gray-400 focus:bg-gray-400"
      type="button"
      onClick={() => selectVideoByIndex(currentVideoIndex + 1)}
    >
      <svg
        xmlns="http://www.w3.org/2000/svg"
        className="h-5 w-5"
        fill="none"
        viewBox="0 0 24 24"
        stroke="currentColor"
      >
        <path
          strokeLinecap="round"
          strokeLinejoin="round"
          strokeWidth={2}
          d="M9 5l7 7-7 7"
        />
      </svg>
    </button>
    <button
      className="p-2 bg-gray-300 hover:bg-gray-400 focus:bg-gray-400"
      type="button"
      onClick={() => setCurrentVideoIndex(null)}
    >
      <svg
        xmlns="http://www.w3.org/2000/svg"
        className="h-5 w-5"
        fill="none"
        viewBox="0 0 24 24"
        stroke="currentColor"
      >
        <path
          strokeLinecap="round"
          strokeLinejoin="round"
          strokeWidth={2}
          d="M6 18L18 6M6 6l12 12"
        />
      </svg>
    </button>
  </div>
  {currentVideoIndex !== null && (
  <>
    <div className="aspect-w-16 aspect-h-9">
    <iframe

  ...

With that our channel detail page is complete, but we still miss an easy way to navigate back to the channel overview page. So let’s add a global navigation.

Global navigation bar

First we create a new component components/nav.js for our navbar. Our navbar consists only of a link back to our homepage.

components/nav.js
import Link from "next/link";

export default function Nav() {
  return (
    <nav className="p-4 lg:p-8">
      <Link href="/">
        <a className="font-bold">Best Kitesurf YouTube Channels</a>
      </Link>
    </nav>
  );
}

We then add our navbar to our _app.js file.

_app.js
import Nav from "components/nav";
import "tailwindcss/tailwind.css";

function MyApp({ Component, pageProps }) {
  return (
    <>
      <Nav />
      <Component {...pageProps} />
    </>
  );
}

export default MyApp;

And remove the headline Best Kitesurf YouTube Channels from our index.js page, as we only need it once.

<h1>Best Kitesurf YouTube Channels</h1>

To finish our Best Kitesurf YouTube Channels website we can add some more great YouTube channels.

pages/index.js
...

export async function getServerSideProps() {
  const channels = [
    "UC0TsOcFEW9FoTP6qdvZIJqA", // Kevin Langeree
    "UCmzxts0YGES5tN-oJ9abTQg", // Kai Lenny
    "UCueYbWdMJ-u7xb7gKE2B2Qw", // Ruben Lenten
    "UCpvzntBAE9Yq3NTOVOdEpqQ", // Aaron Hadlow
    "UCa4U3I1RkE1bVHKQXz1azDw", // Tom Court
    "UCY6tC9P0Vf1uMxcMB2fDn8g", // Sam Light
    "UCbexBMlbpOKHFIdT-8ZeeGA", // Jake Kelsick
    "UCe_SfX2ZD8pRoyE7atPNu2g", // Jeremie Tronet
    "UCR4-Msn347sPVPxdbBXNaZQ", // Gisela Pulido Borrell
    "UCr8Yh0AcsZqsDXl2QKTV7lA", // Jalou Langeree
    "UCPqFFxV6vhkNso2XeDdALOQ", // Bruna Kajiya
    "UCOXq-tcD4X_QmksdqWsfIrA", // Hannah Whiteley
    "UCSRqn7YaHN5s5jIcf9qTgyQ", // Rita Arnaus
  ];

...

Follow along as I'm building awesomereact.com in public.