📑

microCMSとNext.jsを用いて、自分の記事まとめてみた

2023/04/02に公開

概要

以下動画のように私のポートフォリオで、Zennで私がどのような記事を投稿しているかを表示し、タグで絞り込みができるようにしています。クリックすればZennの対象記事のページに遷移することができます。興味のある人は実際のサイトで見てみてください(ポートフォリオ ブログページ)。

最新の記事の情報を反映させるために、記事をZennで投稿した後に、microCMSで記事のタイトルやリンク等を含んだコンテンツを1つ作成して公開するといった一手間は必要になってきますが、労力的には最小限に抑えられていると思います!

今回の機能を作るに至った経緯

Zennは非常に使いやすいと思いながら利用させていただいているのですが、個人の記事を検索で絞るといったことができないので、個人のページで記事の数が増えてきたときに、この人がどのような内容の記事を書いているかがわかりづらくなってしまうと思いました。自分がどのような記事を書いているのかを自分以外の人にわかりやすく伝えるため、今回の機能を作成することにしました。

実装するにあたって、まずZennのAPIがないかを調べましたが、記事作成時点でAPIは存在しなかったため、元々使ってみたかったmicroCMSを用いて実装しました。

microCMS部分

microCMSとは

まずmicroCMSとは、APIベースの日本製の中でも最も代表的なヘッドレスCMSです(公式ページ)。ヘッドレスCMSとはバックエンドの機能だけを用意したCMSの一種です。と言われてもよくわからない人もいるかと思うので少しわかりやすくすると、ヘッドレスCMSとはデータを投稿・管理するための管理画面とそのデータを出力するためのAPIを用意してくれるということです。APIのみを提供して、そのデータをどのように表示するかは各々自由にフロントエンドを作成すればいいということで、ヘッドレス(頭がない)という意味になっています。無料枠もあって簡単に使えるので、実際に使ってみるとどんなものなのかもすぐ掴めると思いますが、イメージ図を以下に示します。

microCMSの使い方

microCMSの使い方に関しては、正直説明する必要がないくらいmicroCMSのドキュメントがわかりやすく、しっかりしているので、細かい部分は割愛します。
Next.js以外での使い方も公式HPを見れば様々なフレームワークとの連携方法が書いてありますが、私が参考にしたリンクを以下に示します。

やることとしてはおおまかに、以下の流れです。

  1. microCMSのアカウント登録等行う
  2. どのようなAPIを作成するか設定する
  3. Next.jsアプリにmicrocms-js-sdkをインストールして、service-domainとapi-Keyの設定
  4. データを取得するためにmicroCMSのAPIにリクエストを投げる処理コードをアプリに記述

microCMSを用いてAPI作成

今回求める機能を実現するためにどのようなAPIを作成したかを紹介します。
まず作ったAPIのコンテンツ内容としては、以下の2つです。
①どのようなブログを投稿したのかを管理する
②ブログにつけるタグを管理する

②のタグ管理用APIのAPIスキーマは以下画像の通りです。現在はタグ名によって絞り込みする機能しか実装していないので、タグの分類というカラムは不要です(今後欲しくなるかもと思って作成しただけです)。

上図のスキーマを基に登録したコンテンツは以下の通りです。

どのようなタグで記事を分類したいかを考え、タグを追加しています。先ほどAPIとして①、②の2つを作ったという話をしましたが、実際は①だけでもセレクトフィールドのタグ用カラムを作れるので、②は作らなくても実装できますが、その場合タグだけの情報を取得する際やタグの並び替えなどで不便な点があったため、②でタグを管理し、①と連携させるという方法を取りました。

では①の方もAPIスキーマを紹介しましょう。

カラムについては以下のようになっています。

  • タイトル名:Zennのタイトルをそのままコピペします
  • ブログへのURL:記事のURLをそのままコピペします
  • 投稿日:記事投稿日
  • 関連するタグ:②で作成したタグを複数選択できる

ポイントとしては関連するタグのスキーマの種類を「複数コンテンツ参照」というもので②のコンテンツを選択すれば、②のタグと連携され、①のコンテンツ作成時に②のタグがリストとして表示され、複数選択することができるようになります。

このスキーマを使用して作成したコンテンツの中身は下図のようになります。

運用方法

Zennで記事を投稿した後、microCMSで記事のタイトルやURLをコピーして、①のコンテンツを追加します。ここがZennで記事投稿→microCMSのコンテンツの追加が自動で行うことができればもっといいのになぁと思っています。

Next.js部分

コードの全体像

ブログページのコードを以下に示します。pages/blog.tsxの中に記事カードのコンポーネント(components/Blog/BlogCard/index.tsx)が展開される感じです。microCMSのAPIを叩くコードはblog.tsxに記述しています。また、UIライブラリとしてMUIを使用しています。
一応ソースコードへのリンクも貼っておきます(ソースコード)。

pages/blog.tsx
pages/blog.tsx
import { Box, Container, FormControl, Grid, InputLabel, Link, MenuItem, Select, SelectChangeEvent, Typography } from "@mui/material"
import { Footer } from "../components/layouts/Footer"
import Header from "../components/layouts/Header"
import { HeadTag } from "../components/layouts/HeadTag"
import { PageTitle } from "../components/PageTitle"
import { client } from "../libs/client"
import { BlogTag, PeiBlog} from "../types/blog"
import { useState } from "react"
import { BlogCard } from "../components/Blog/BlogCard"

// microCMSへAPIリクエスト
export const getStaticProps = async () => {
  const blogs = await client.get({ endpoint: "pei_blog?limit=50" });
  const tags = await client.get({ endpoint: "blog_tag?limit=25" });
  blogs.contents.map((blog: PeiBlog) => blog.visible = true)

  return {
    props: {
      blogs: blogs.contents,
      tags: tags.contents,
    },
  };
};

// Props(blogsとtags)の型
type Props = {
  blogs: PeiBlog[];
  tags: BlogTag[];
};

function Blog({blogs,tags}: Props) {
  // タグ選択state
  const ALLARTICLE: string = "全ての記事" 
  const [selectTag, setSelectTag] = useState<string>(ALLARTICLE);
  const handleTagSelect = (e: SelectChangeEvent<string>) => {
    setSelectTag(e.target.value);
  };

  // 表示内容
  return (
    <>
      <HeadTag />
      <main>
        <Header />
        <Container maxWidth='md'>
          <Box mb={6}>
            <PageTitle title="Blog." />
            {/* ページの説明 */}
            <Typography align='center'>
              週1ペースでZennに技術ブログを書いています(<Link href="https://zenn.dev/peishim" target="_blank" rel="noopener noreferrer">Zenn個人ページ</Link><br/>
              このページでは私が書いたブログをタグ検索することができます。
            </Typography>
            {/* タグ選択 */}
            <Box my={2} sx={{ display: 'flex', justifyContent: 'center'}} >
              <FormControl variant="outlined" sx={{ m: 1, minWidth: 200 }} >
                <InputLabel id="select-tag-label" color="primary" >Tag</InputLabel>
                <Select labelId="select-tag-label" id="select-tag" value={selectTag} label="Tag" onChange={e => handleTagSelect(e)}>
                  <MenuItem value={ALLARTICLE} >{ALLARTICLE}</MenuItem>
                  {tags.map((tag) => ( <MenuItem value={tag.name} key={tag.name}>{tag.name}</MenuItem> ))}
                </Select>
              </FormControl>
            </Box>
            {/* ブログへのリンク */}
            <Box mt={6}>
              <Grid container spacing={4} >
                {blogs.map((blog) => (
                  // selectTagによって表示・非表示が切り替わる
                  <BlogCard blog={blog} selectTag={selectTag} key={blog.id} />
                ))}
              </Grid>
            </Box>
          </Box>
        </Container>
        <Footer />
      </main>
    </>
  )
};

export default Blog
components/Blog/BlogCard/index.tsx
components/Blog/BlogCard/index.tsx
import { Card, CardContent, Chip, Grid, Stack, Typography } from "@mui/material";
import NextLink from "next/link";
import { useEffect, useState } from "react";
import { getDateStr } from "../../../libs/getDateStr";
import { PeiBlog } from "../../../types/blog";

export function BlogCard(props: {blog: PeiBlog, selectTag: string, key: string}) {
  // メモ:1回のタグ切り替えで記事×2回分のレンダリングが発生している

  const blog = props.blog;
  const selectTag = props.selectTag;

  // 記事の表示・非表示state
  const [visible, setVisible] = useState<boolean>(blog.visible);

  useEffect (() => {
    if (selectTag === '全ての記事') {
      setVisible(true);
    } else {
      const tagNameList: string[] = blog.tags.map((tag) => tag.name);
      if (tagNameList.includes(selectTag)) {
        setVisible(true)
      } else {
        setVisible(false);
      }
    }
  }, [selectTag])

  return (
    <Grid item xs={12} md={4} key={blog.id} style={{display: visible? "block" : "none"}}>
    <NextLink href={blog.url} target="_blank" rel="noopener noreferrer">
      <Card variant="outlined" style={{backgroundColor: "#fff8dc", display: visible? "block" : "none"}} >
        <CardContent>
          <Typography color='#a9a9a9'>
            {getDateStr(blog.date)}
          </Typography>
          <Typography>
            {blog.title}
          </Typography>
          <Stack direction='row' spacing={1} mt={1}>
          {blog.tags.map((tag) => 
            <Chip label={tag.name} variant="outlined" color="secondary" key={tag.id} size="small" />
          )}
          </Stack>
        </CardContent>
      </Card>
    </NextLink>
    </Grid>
  );
}

microCMSのAPIを叩く

以下のようなコードを書き、microCMSのAPIを叩いて、自分がmicroCMSの管理画面で追加したコンテンツのデータを取得しています。

pages/blog.tsxの一部
// microCMSへAPIリクエスト
export const getStaticProps = async () => {
  const blogs = await client.get({ endpoint: "pei_blog?limit=50" });
  const tags = await client.get({ endpoint: "blog_tag?limit=25" });
  blogs.contents.map((blog: PeiBlog) => blog.visible = true)

  return {
    props: {
      blogs: blogs.contents,
      tags: tags.contents,
    },
  };
};

blogsに記事の全データ、tagsにタグの全データを入れます。

APIを叩いてblogsに格納されるデータの一部
{
    "contents": [
        {
            "id": "dbupj18-77ib",
            "createdAt": "2023-03-26T07:43:22.451Z",
            "updatedAt": "2023-03-26T07:43:22.451Z",
            "publishedAt": "2023-03-26T07:43:22.451Z",
            "revisedAt": "2023-03-26T07:43:22.451Z",
            "title": "Next.jsでお問い合わせフォーム作成",
            "url": "https://zenn.dev/peishim/articles/c403e61b9898b0",
            "date": "2023-03-25T15:00:00.000Z",
            "tags": [
                {
                    "id": "374nv750zsdv",
                    "createdAt": "2023-02-25T08:57:49.492Z",
                    "updatedAt": "2023-03-12T08:49:02.638Z",
                    "publishedAt": "2023-02-25T08:57:49.492Z",
                    "revisedAt": "2023-02-25T09:04:11.126Z",
                    "name": "Next.js",
                    "group": [
                        "フロントエンド"
                    ]
                },
                {
                    "id": "ydpxy9engs",
                    "createdAt": "2023-02-25T08:44:03.045Z",
                    "updatedAt": "2023-03-12T08:49:22.944Z",
                    "publishedAt": "2023-02-25T08:44:03.045Z",
                    "revisedAt": "2023-02-25T09:03:59.943Z",
                    "name": "JS,TS",
                    "group": [
                        "フロントエンド"
                    ]
                }
            ]
        },

さらにblogsにはタグ選択によって表示・非表示を切り替えるためのvisibleというキーを追加しています。初めは、全ての記事を表示させたいので、全ての記事でblog.visible = trueとしています。

APIを叩く時の工夫としてSSGを使用しています。以下リンクなどSSGに書いている記事はたくさんあるので、詳しくは他を参照していただければと思います。
SPA, SSR, SSGって結局なんなんだっけ?
要は、SSGを用いることで、ビルド時にこのAPIを叩いてブログやタグの全データを取得してくれるので、クライアント側からサイトにアクセスするたびに、APIを叩く必要がない→処理が高速になるし、仮にいっぱいサイトにアクセスがきてもAPIを叩かないので、microCMSの無料枠を超えることがなくなるということです。

タグによって記事の表示・非表示の切り替え

まずselectTagにはブログページで選択した1つのタグ名が入ります。以下コードで各記事のカード(BlogCardコンポーネント)を展開する部分でpropsにselectTag(つまりどのタグが選択されているか)を渡します。

pages/blog.tsxの一部
{blogs.map((blog) => (
  // selectTagによって表示・非表示が切り替わる
  <BlogCard blog={blog} selectTag={selectTag} key={blog.id} />
))}

BlogCardのコンポーネント内に渡されたタグ名によって、それぞれの記事で表示・非表示を切り替えます。

components/Blog/BlogCard/index.tsxの一部
<Card variant="outlined" style={{backgroundColor: "#fff8dc", display: visible? "block" : "none"}} >

上記のようにMUIのCardコンポーネントのstyleでdisplayを設定し、三項演算子で表示・非表示を切り替えます。visibleがtrueなら表示、falseなら非表示となります。

このvisibleはもちろん先ほど渡したpropsのselectTagを基に変化する値となります。
次に以下コードを見てみましょう。

components/Blog/BlogCard/index.tsxの一部
  // 記事の表示・非表示state
  const [visible, setVisible] = useState<boolean>(blog.visible);

  useEffect (() => {
    if (selectTag === '全ての記事') {
      setVisible(true);
    } else {
      const tagNameList: string[] = blog.tags.map((tag) => tag.name);
      if (tagNameList.includes(selectTag)) {
        setVisible(true)
      } else {
        setVisible(false);
      }
    }
  }, [selectTag])

上記は一部なのでコード全体を見ないと何が入っているかわからない変数もあるのですが、propsで渡されるselectTagが変更されたら、useEffectが走ります。selectTagが全ての記事であれば、どんな記事であろうとvisibleはtrueになります。それ以外に関しては、まず記事はタグを複数持っています(Next.jsとJS,TS等のように)。なのでその記事が持つタグを配列にして、その中にselectTagがあるかincludesメソッドを用いて判別し、visibleがtrueかfalseのどちらかをセットします。

実際に運用するためにもう一工夫

先ほどmicroCMSの部分で、運用方法として、Zennで記事を投稿後、microCMSでもコンテンツを追加するという話をしました。しかし、このままではNext.jsの部分でSSGを使用してmicroCMSからデータを取得するため、ビルド時にしかデータを取得することができません。私はVercelを用いてサイトを公開していますが、サイトに反映させるためには再度デプロイしてアプリをビルドし直す必要があり、非常に面倒でした。最後にこの問題を解消しましょう。

方法として、vercelではデプロイフックというものを作成できます。これはデプロイするためのURLを作成し、そこにリクエストを送れば、vercelがアプリをデプロイしてくれるというものです。また、microCMSでwebhookというものを設定できます。これはmicroCMSで変更があった場合に連携しているサービスのURLにリクエスト送れますよといった感じで、どのような変更があった際に動作するのかというタイミングも色々カスタマイズすることができます。これらを用いて、microCMSで変更があった場合に、VercelのデプロイフックのURLを設定しておけば、自動でNextアプリをデプロイしてくれて、記事も最新の状態になるということです。

こちらも以下リンクで公式がわかりやすく教えてくれているので、簡単にできるはずです!

最後に

長くなってしまいましたが、microCMSやNext.jsを利用した機能を作りたい人の参考になれば幸いです。

Discussion