iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
✒️

Building a Simple Blog - Part 2 (with Next.js & Contentful)

に公開

Introduction

This article is a continuation of the previous one. Please check out the previous article as well!
https://zenn.dev/tsuboi/articles/99458811a003c7


Goals of this Article

  • Introduce Tailwind CSS
  • Display different types of articles based on category
  • Add dark mode
  • Deploy to Vercel

We aim to achieve these four goals.

Finished Product
https://next-blog-lime.vercel.app/


Introducing Tailwind CSS

We will introduce Tailwind CSS for styling. The official documentation provides instructions on how to integrate Tailwind CSS with Next.js.
https://tailwindcss.com/docs/guides/nextjs

Tailwind CSS is also easy to customize.

tailwind.config.js
module.exports = {
  purge: ['./pages/**/*.{tsx}', './components/**/*.{tsx}'],
  darkMode: false, // or 'media' or 'class'
  theme: {
+   colors: {
+     black: '#000',
+     white: '#fff',
+     blue: '#1985A1',
+     purple: '#8257e6',
+     orange: '#fa923f',
+     subtext: '#617482',
+     darkblue: '#06202A'
+   },
+   animation: {
+     none: 'none',
+     spin: 'spin 1s linear infinite',
+     ping: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite',
+     pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
+     bounce: 'bounce 1s infinite'
+   }
  },
  variants: {
    extend: {}
  },
  plugins: []
}

Also, if you install the Color Highlight extension for VS Code, colors can be visualized.

As an example, styling can be done concisely.

import React from 'react'

const index = () => {
  return (
    <div className="flex w-full mt-5 justify-evenly">
      <pc className="w-5 h-5 rounded-full animate-bounce bg-orange" />
      <pc className="w-5 h-5 rounded-t-sm animate-spin bg-blue" />
      <pc className="w-5 h-5 rounded-full animate-ping bg-purple" />
      <pc className="w-5 h-5 rounded-full animate-pulse bg-subtext" />
      <pc className="w-5 h-5 rounded-full animate-none bg-darkblue" />
    </div>
  )
}

export default index


Writing Code

We will now prepare to achieve the remaining three goals mentioned earlier.

First, let's style with Tailwind CSS.

_document.tsx
import React from 'react'
import Document, { Html, Head, Main, NextScript } from 'next/document'

class MyDocument extends Document {
  render() {
    return (
      <Html lang="en">
        <Head>
	  <title>Next Blog</title>
          <link rel="preconnect" href="https://fonts.gstatic.com" />
          <link
            href="https://fonts.googleapis.com/css2?family=Kaushan+Script&display=swap"
            rel="stylesheet"
          />
        </Head>
        <body className="bg-fixed bg-gradient-to-r from-orange to-purple">
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

export default MyDocument
index.tsx
import React, { useState } from 'react'
import Head from 'next/head'
import { createClient, EntryCollection } from 'contentful'
import { InferGetStaticPropsType, NextPage } from 'next'
import BlogCard from '../components/BlogCard'
import Sidebar from '../components/BlogNavbar'
import { Category, IFields } from '../../types'

export const getStaticProps = async () => {
  const client = createClient({
    space: process.env.CONTENTFUL_SPACE_ID,
    accessToken: process.env.CONTENTFUL_ACCESS_KEY
  })

  const response: EntryCollection<IFields> = await client.getEntries({
    content_type: 'article'
  })

  return {
    props: {
      article: response.items
    }
  }
}

type Props = InferGetStaticPropsType<typeof getStaticProps>

const Home: NextPage<Props> = ({ article }) => {
  return (
    <div className="px-5 py-2 overflow-scroll">
      <div className="grid grid-cols-12 gap-6 px-5 my-14 lg:mb-0 md:mb-16 sm:px-20 md:px-32 lg:px-36 xl:px-48 ">
        <div className="h-full col-span-12 p-4 text-base text-center bg-white  lg:col-span-3 rounded-2xl shadow-custom-light">
          <Sidebar/> 
        </div>
        <div className="flex flex-col col-span-12 overflow-hidden bg-white shadow-custom-light rounded-2xl lg:col-span-9">
          <div className="relative grid grid-cols-12 gap-4 my-3">
            {article.map(item => (
              <div
                key={item.sys.id}
                className="col-span-12 p-2 bg-gray-200 rounded-lg sm:col-span-6 lg:col-span-4"
              >
                <BlogCard item={item} />
              </div>
            ))} 
          </div>
        </div>
      </div>
    </div>
  )
}

export default Home

We connect Next.js and Contentful using SSG. Please refer to the previous article for details. However, the previous article had an issue with props type inference, resulting in any type, which has been corrected. (The previous article has also been updated.)

The appearance is as follows:

From now on, the left side will be referred to as the Sidebar component and the right side as the BlogCard component.


BlogCard Component

component/BlogCard.tsx
import React from 'react'
import { IFields } from '../../types'
import Image from 'next/image'
import { NextPage } from 'next'
import { Entry } from 'contentful'

const BlogCard: NextPage<{ item: Entry<IFields> }> = ({ item }) => {
  return (
    <>
      <Image
        src={'https:' + item.fields.thumbnail.fields.file.url}
        alt={item.fields.title}
        className="object-contain cursor-pointer"
        layout="responsive"
        height="300"
        width="400"
      />
      <pc className="my-2 text-center">{item.fields.title}</p>
    </>
  )
}

export default BlogCard

We use Next.js's Image tag for images. When loading external files (Contentful) like this, you need to create a next.config.js file and add the following:

next.config.js
module.exports = {
  images: {
    domains: ['images.ctfassets.net'] // ←Changes depending on the external file
  }
}

Also, we will add a feature where clicking on each article's image magnifies the image, and clicking the detail (Link tag) leads to the respective article's link.

component/BlogCard.tsx
import React, { useState } from 'react'
import { IFields } from '../../types'
+import { XIcon } from '@heroicons/react/solid'
import Image from 'next/image'
import Link from 'next/link'
import { NextPage } from 'next'
import { Entry } from 'contentful'

const BlogCard: NextPage<{ item: Entry<IFields> }> = ({ item }) => {
+ const [showDetail, setShowDetail] = useState(false)

  return (
    <>
      <Image
        src={'https:' + item.fields.thumbnail.fields.file.url}
        alt={item.fields.title}
        className="object-contain cursor-pointer"
        onClick={() => setShowDetail(true)}
        layout="responsive"
        height="300"
        width="400"
      />
      <pc className="my-2 text-center">{item.fields.title}</p>

+     {showDetail && (
+       <div className="absolute top-0 left-0 z-10 grid w-full h-full p-2 text-black bg-white md:grid-cols-2 gap-x-12">
+         <Image
+           className="object-contain"
+           src={'https:' + item.fields.thumbnail.fields.file.url}
+           alt={item.fields.title}
+           layout="responsive"
+           height="150"
+           width="300"
+           />
+           <div className="flex justify-center my-4 space-x-3">
+             <Link href={'/article/' + item.fields.slug}>
+               <pa className="flex items-center px-4 space-x-3 text-2xl +font-bold bg-gray-200 hover:text-orange">
+                 detail
+               </pa>
+             </Link>
+             <div className="flex items-center text-sm">
+               {item.fields.type.map(tech => (
+                 <span
+                   key={tech}
+                   className="px-2 py-1 my-1 text-subtext"
+                 >
+                   [{tech}]
+                 </span>
+               ))}
+             </div>
+           </div>

+           <button
+             onClick={() => setShowDetail(false)}
+             className="absolute p-1 bg-gray-200 rounded-full top-3 right-3 focus:outline-none"
+           >
+             <XIc on className="h-8" />
+           </button>
+         </div>
+       )}
    </>
  )
}

export default BlogCard


We are using heroicons for icons.
https://github.com/tailwindlabs/heroicons

By the way, item.fields.type (e.g., [dog], [husky] in the image) is one of the Content models created in Contentful. We will use this in the next chapter.


Displaying Articles Based on Article Type

With the preparations complete, we will now move on to the second goal.

First, we will modify the index component to facilitate cooperation between the Sidebar component and the BlogCard component.

index.tsx(SSGは除く)
const Home: NextPage<Props> = ({ article }) => {
+ const [articles, setArticles] = useState(article)
+ const [active, setActive] = useState('dog')

+  const handlerFilterCategory = (category: Category) => {
+    if (category === 'dog') {
+      setArticles(article)
+      setActive(category)
+      return
+    }
+    const newArray = article.filter(item => item.fields.type.includes(category))

+    setArticles(newArray)
+    setActive(category)
+  }

  return (
    <div className="px-5 py-2 overflow-scroll">
      <div className="grid grid-cols-12 gap-6 px-5 my-14 lg:mb-0 md:mb-16 sm:px-20 md:px-32 lg:px-36 xl:px-48 ">
        {/* // do this div style later (after putting the content) */}
        <div className="h-full col-span-12 p-4 text-base text-center bg-white lg:col-span-3 rounded-2xl shadow-custom-light">
          <Sidebar
+           handlerFilterCategory={handlerFilterCategory}
+           active={active}
          />
        </div>
        <div className="flex flex-col col-span-12 overflow-hidden bg-white shadow-custom-light rounded-2xl lg:col-span-9">
          <div className="relative grid grid-cols-12 gap-4 my-3">
-	     {article.map(item => (
+            {articles.map(item => (
              <div
                key={item.sys.id}
                className="col-span-12 p-2 bg-gray-200 rounded-lg sm:col-span-6 lg:col-span-4 "
              >
                <BlogCard item={item} />
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  )
}

export default Home

Key Points

First, prepare two useState hooks.

The first useState takes the data (article) obtained from contentful as an argument and updates the content of the article based on conditions. The handlerFilterCategory function handles this, determining whether all of the article's content or only a part of it is needed based on the argument (category).
If only a part is needed, an array operation is performed using the filter method. If the type of each article created in Contentful includes the category argument of the handlerFilterCategory function, the article's content is updated. This is set as newArray, and state management is performed by setState(setArticles).
The latest state value (articles) is then passed to the BlogCard component via props.

The second useState is prepared for styling with Tailwind CSS. It will be introduced in the following Sidebar component code.

components/BlogSidebar.tsx
import React from 'react'
import { Category } from '../../types'

export const NavItem: React.FC<{
  value: Category
  handlerFilterCategory: (category: Category) => void
  active: string
}> = ({ value, handlerFilterCategory, active }) => {
  let className = 'capitalize cursor-pointer hover:text-orange'
  if (active === value) className += ' text-orange'

  return (
    <li className={className} onClick={() => handlerFilterCategory(value)}>
      {value}
    </li>
  )
}

const BlogNavbar: React.FC<{
  handlerFilterCategory: (category: Category) => void
  active: string
}> = props => {
  return (
    <div className="min-h-full ">
      <h3 className="font-mono text-3xl font-medium tracking-wider text-purple">
        Blog
      </h3>
      <div className="flex flex-col mt-10 space-x-3 space-y-6 overflow-x-auto list-none">
        <NavItem value="dog" {...props} />
        <NavItem value="Border Collie" {...props} />
        <NavItem value="bulldog" {...props} />
        <NavItem value="husky" {...props} />
      </div>
    </div>
  )
}

export default BlogNavbar

Key Points

The BlogNavbar component receives handlerFilterCategory and active as props from the index component. These are then spread as props to each NavItem component.
The NavItem component receives handlerFilterCategory, active, and value via prop drilling. The value is passed as an argument to handlerFilterCategory, and if the strings active and value match, the text is colored.

With these changes, we have enabled displaying articles based on article type.


Adding Dark Mode

The steps for introducing dark mode are described in detail in the official Tailwind CSS documentation.
https://tailwindcss.com/docs/dark-mode

Following the official guide, to enable dark mode in Tailwind CSS, you need to modify the tailwind.config.js file.

tailwind.config.js
module.exports = {
  purge: ['./pages/**/*.{tsx}', './components/**/*.{tsx}'],
- darkMode: 'false',
+ darkMode: 'class', 

......

}

You also need to install one library.
https://github.com/pacocoursey/next-themes

​$ npm install next-themes
# or
$ yarn add next-themes

After installing the library, import ThemeProvider into _app.js.

_app.tsx
import React from 'react'
import '../styles/globals.css'
import type { AppProps } from 'next/app'
import Head from 'next/head'

+import { ThemeProvider } from 'next-themes'

function MyApp({ Component, pageProps }: AppProps): React.ReactNode {
  return (
    <>
      <Head>
        <title>Next Blog</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>
+     <ThemeProvider attribute="class">
        <Component {...pageProps} />
+     </ThemeProvider>
    </>
  )
}

export default MyApp

Now, simply apply styles by prefixing them with dark: where you want dark mode to be applied.

_document.tsx
import React from 'react'
import React from 'react'
import Document, { Html, Head, Main, NextScript } from 'next/document'

class MyDocument extends Document {
  render() {
    return (
      <Html lang="en">
        <Head>
          <link rel="preconnect" href="https://fonts.gstatic.com" />
          <link
            href="https://fonts.googleapis.com/css2?family=Kaushan+Script&display=swap"
            rel="stylesheet"
          />
        </Head>
+       <body className="bg-fixed bg-gradient-to-r from-orange to-purple dark:from-subtext dark:to-darkblue dark:text-white">
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

export default MyDocument

Additionally, we will add a feature to switch between dark and light modes by clicking a button to the Sidebar component.

components/BlogNavbar.tsx
import React from 'react'
+import { useTheme } from 'next-themes'
import { Category } from '../../types'
+import { SunIcon } from '@heroicons/react/outline'
+import { MoonIcon } from '@heroicons/react/solid'

export const NavItem: React.FC<{
  value: Category
  handlerFilterCategory: (category: Category) => void
  active: string
}> = ({ value, handlerFilterCategory, active }) => {
  let className = 'capitalize cursor-pointer hover:text-orange'
  if (active === value) className += ' text-orange'

  return (
    <li className={className} onClick={() => handlerFilterCategory(value)}>
      {value}
    </li>
  )
}

const BlogNavbar: React.FC<{
  handlerFilterCategory: (category: Category) => void
  active: string
}> = props => {
+ const { theme, setTheme } = useTheme()
+ const changeTheme = () => {
+   setTheme(theme === 'light' ? 'dark' : 'light')
+ }

  return (
    <div className="flex flex-col justify-between min-h-full">
      <div className="flex justify-between">
        <ch3 className="text-3xl font-medium tracking-wider text-purple font-kaushan dark:text-subtext">
          Blog
        </h3>
+       {theme === 'light' ? (
+         <SunIcon className="h-8" />
+       ) : (
+         <MoonIcon className="h-8" />
+       )}
      </div>
      <div className="flex flex-col py-6 space-x-3 space-y-3 overflow-x-auto list-none">
        <NavItem value="dog" {...props} />
        <NavItem value="Border Collie" {...props} />
        <NavItem value="bulldog" {...props} />
        <NavItem value="husky" {...props} />
      </div>
+     <button
+       onClick={changeTheme}
+       className="py-2 my-4 text-white bg-black rounded-full cursor-pointer bg-gradient-to-r from-orange to-purple dark:from-subtext dark:to-darkblue focus:outline-none hover:scale-105"
+     >
+       {theme === 'light' ? 'Light Mode' : 'Dark Mode'}
+     </button>
    </div>
  )
}

export default BlogNavbar

With these changes, dark mode has been introduced.


Deploying to Vercel

There are minimum requirements for deploying to Vercel.

Click here to log in to Vercel:
https://vercel.com/

Steps to deploy after logging in

  1. Log in to Vercel.
  2. Click "New Project".
  3. Import Git Repository (select the repository you want to deploy).
  4. Select Vercel Scope (select your GitHub account).

Finally, "Import Project".
If environment variables are required, as in this case, copy and paste them from your .env file.

Then, click "Deploy" and it will commend you with "Congratulations!"
(I forgot to take a screenshot...)
This completes the deployment to Vercel.

That's all for this article.
Thank you for reading this far!!

Discussion