ロゴ

Next.Js+MicroCMSでページング付きカテゴリ別記事一覧を実装する

作成日:2022年7月2日

目次

はじめに

NextJs+Typescript+MicroCMS構成のブログ作成チュートリアルは様々なQAサイト、ブログに記事があります。
それら記事を拝見しながら作った本ブログですが、
技術的に一歩踏み込んだところで、「どう実装するんだ?」となってしまった部分がいくつかありました。

その中でも今回は、「ページング付きの、カテゴリ別記事一覧」の実装方法についてまとめてみようという試みです。

前提

今回の記事は、以下の知識があることを前提とした説明になっています。

  • GetStaticProps、GetStaticPathsの大まかな挙動を理解している。
  • TypeScriptの基礎(型や宣言)について理解している。


また、本記事はNextJsでSSGをする方法、MicroCMSのAPI連携部分の実装等、いろいろとすっ飛ばして説明しています。
基本的なブログの作成方法に関する、記事は下記リンクを参考にしてください。

microCMS + Next.jsでJamstackブログを作ってみよう|MicroCMS公式Blogより

環境構築~デプロイの手順がとってもわかりやすくまとまっています。
(MicroCMS公式ブログはわかりやすくて、ためになる優良記事ばかりで、控えめに言って、最高です。
Next.Jsについての記事もたくさんありますので、他記事を拝読することもお勧めします。)

実装

ファイル作成

pagesディレクトリに、category/[categoryId]/page/[page].tsxを追加します。

ディレクトリ構成

pages
|-category
    |-[categoryId]
        |-page
           |- [page].tsx  <=追加
|-_app.tsx
|-index.tsx


pages.tsx

import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
//1ページに表示する最大記事数
const MAX_PAGE =10 as const;

type Props={
  data:IMicroCMSBlogRes,
  page:number,
  categoryName:string,
  categoryId:string
}

const CategoryPage:NextPage<Props> =({categoryName,data,page,categoryId})=>{
  return(
     <></>
    )
}

export const getStaticPaths:GetStaticPaths =async () =>{
    return //GetStaticPathsの結果オブジェクト
}

export const getStaticProps:GetStaticProps=async({params}:GetStaticPropsContext)=>{
   return //Props
}
export default CategoryPage;

GetStaticPaths、GetStaticProps、NextPageの中身はとりあえず空にしています。

個人的な好みで、コンポーネントごとに固有のpropsは 
type Props={}
で統一していますが、命名は好みで変えてください。

今回使用するtypescriptの型は以下のようになっています。

interfaces/microCMS.ts

//MicroCMSAPIからの取得記事
export interface IArticle {
    id: string
    createdAt: string
    updatedAt: string
    publishedAt: string
    revisedAt: string
    title: string
    content: string
    description:string
    eyecatch:{
        url: string
    height: number
    width: number
    }
    category:ICategory
}

export interface ICategory{
    id:string
    name:string
}

//MicroCMAPIからのレスポンス固定のパラメータ
export interface IMicroCMSRes{
  totalCount: number,
    offset: number,
    limit: number
}

//BlogエンドポイントのAPIレスポンス
export interface IMicroCMSBlogRes extends IMicroCMSRes{
  contents:IArticle[]
}
//CategoryエンドポイントのAPIレスポンス
export interface IMicroCMSCategoriesRes extends IMicroCMSRes{
  contents:ICategory[]
}


GetStaticPaths内の処理

GetStaticPathsの処理です。

export const getStaticPaths:GetStaticPaths =async () =>{
 //全カテゴリ取得
  const categories =await GetAllCategories();
 
  const pathsNotFlat = await Promise.all(
    categories.map((category:ICategory) => {
   //カテゴリごとに記事数を取得し、何ページ必要かを計算
      return GetPostsCountInCategories(category.id)
        .then((totalCount) => {
          const range = (start: number, end: number) =>
            [...Array(end - start + 1)].map((_, i) => start + i);
          //ページの最大値を計算(記事数0でも表示)
          const pagesEnd =Math.max(1,Math.ceil(totalCount / MAX_PAGE));
          return range(1, pagesEnd ).map(
            page => `/category/${category.id}/page/${page}`
          )
      });
    })
  )
  const paths=pathsNotFlat.flat();

  return { paths, fallback: false }
}


解説していきます。

まず、await GetAllCategories() で、MicroCMSに登録されているブログのカテゴリを全て取得します。

GetAllCategories()内の処理については、MicroCMSに登録されている全カテゴリを取得し、
IMicroCMSCategoriesResの型で返しているだけなので割愛します。

次に、Promise.All()内で、取得したカテゴリごとに、紐づいている記事の数を取得しています。
MicroCMSのデータ取得APIには、SQLで言うJoinにあたるクエリが無い(無いかもしれない)ので、

カテゴリ全取得 → 各カテゴリごとに記事数取得

という、とってもめんどくさいプロセスを踏んでいます。
(今思えば全記事を取得してreduce()でカテゴリごとの記事数をまとめたほうが良かったのかも、、)


GetPostsInCategories()では、該当するIDのカテゴリの記事件数を取得してきます。
関数の中身はこのようになっています。

lib/posts.ts

export async function getPostsCountInCategories(id:string):Promise<number>{
  const data:IMicroCMSCategoriesRes =await client.get({
                                                        endpoint: 'blogs',
                                                        queries: {filters: `category[equals]${id}`,fields: "totalcount"}
                                                        });
  return data.totalCount;
}

APIでカテゴリに紐づいているブログ数を取得しています。
この関数をPromiseで返し、PromiseAll()で全カテゴリの件数取得を待ちます。

続いて、GetPostsCountInCategories()のメソッドチェーンになっている.then()の中身についてです。
range()で、表示するすべてのページ番号を配列で取得します。

const range = (start: number, end: number) =>[...Array(end - start + 1)].map((_, i) => start + i);

start~endの数値の連番を配列を返します。
range(1,5)は[1,2,3,4,5]を返すといった具合です。

ちなみに、

Array(end-start+1).map((_, i) => start + i);//×Array()がemptyの配列を返すため、map()が機能しない。

としてしまうと、一見動きそうですが動かないので注意です。参考:【JavaScript】Array.prototype.map()とemptyな配列
動的に連番を取得する上記の関数は地味によく使うので、テンプレとして覚えておくことをお勧めします

カテゴリごとの記事の最大ページ数を計算します。

const pagesEnd = Math.max(1,Math.ceil(totalCount / MAX_PAGE));

「総記事数÷1ページに表示したい記事数」を切り上げることで、ページ数が取得できます。
この時の注意点としては、記事数0、すなわちページ数0でもページを表示できるようにすることです。
今回はMath.max()を使用して、ページ数の最小値を1にしています。

SSGするページのルーティングを文字列で返します。
当然ですが生成するパスは、category/[categoryId]/page/[page].tsxのディレクトリ名と一致させてください。

return range(1, pagesEnd ).map(
            page => `/category/${item.id}/page/${page}`
          )


pathsオブジェクトで返したい場合は、paramsを持ったオブジェクトを返してください。

//これでもいけます。
return range(1, pagesEnd ).map(
            page => {
              params:{
                category:item.id
                page:page
              }
            }
          )


await PromiseAll()によって得られる値は、配列にしたルーティングを、カテゴリでごとにさらに配列したものになります。
string[][]となってしまっているので、string[]に直してあげます。

 const paths=pathsNotFlat.flat();//string[][]=>string[]


GetStaticProps内の処理

getStaticPropsの処理です。

export const getStaticProps:GetStaticProps=async({params})=>{
   const categoryId = String(params?.categoryId);//カテゴリIDを取得
   const categoryPage = Number(params?.page);//ページ
   const data = await getPostsInCategories(MAX_PAGE,categoryPage-1,categoryId);//記事を取得
   const categoryName = await GetCategoryName(categoryId);//カテゴリ名を取得
   return {
    props:{
      data,
      categoryPage,
      categoryName,
      categoryId
    }
   }
}


paramsのメンバにはgetStaticPathsで定義したcategoryId,pageが含まれていますが、
今回の例では、getStaticPathsの戻り値を厳密に型指定していないので、補完が効かないことに注意してください
getStaticPaths、getStaticPropsの引数を厳密に型指定したい方はこちらが参考になります。

[Next] getStaticPropsの型の付け方、型定義について|zenn

一覧にする記事情報をawait getPostsInCategories()で取得します。
ブログ取得のクエリにoffsetとlimitを指定して、
「先頭から何件目」を、「何件取得するのか」を指定することで、ページごとの記事一覧を取得できます。

 

export async function getPostsInCategories(count:number,offset:number,id:string):Promise<IMicroCMSBlogRes>{
  const data:IMicroCMSBlogRes =await client.get({ endpoint: 'blogs',
                                                  queries: {filters: `category[equals]${id}`, 
                              limit: count, 
                              offset: offset}});
  return data;
}

カテゴリ名はわざわざAPIで取得せずとも、記事一覧から取得可能ですが、今回はわかりやすくするために、APIで取得するようにしています。
await GetCategoryName()で、カテゴリIDに対応するカテゴリ名を取得しています。

ページネーションの実装

最後に、getStaticPropsによって取得した記事の一覧データを使って、NextPage関数内でページネーションを実装します。
ページネーションは、MUIを使うと簡単に実装できます。

MUIをインストールします。

// npm
npm install @mui/material @emotion/react

// yarn
yarn add @mui/material @emotion/react


@mui/materialからPaginationコンポーネントをインポートし、各propsを割り当てます。
詳しくは、MUI/Pagination|MUI公式を参考にしてください。

import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
import { Pagination } from '@mui/material';//追加
//1ページに表示する最大記事数
const MAX_PAGE =10 as const;

//~~~~~~~~~~~~~~~

const CategoryPage:NextPage<Props> =({categoryName,data,page,categoryId})=>{
 const totalPagesCount = Math.ceil(data.totalCount/MAX_PAGE);//記事数からページ数を計算
  return(
     <>
       <Pagination 
        count={totalPagesCount} 
        page={page} 
        siblingCount={3} />
     </>
    )
}


ページネーションをnext/linkで遷移できるようにします。
next/routerからuseRouterモジュールをインポートし、
PaginationコンポーネントのonChangeイベントで、routerのpushイベントを発火させると、
任意のページに遷移できます

const CategoryPage:NextPage<Props> =({categoryName,data,page,categoryId})=>{
 const totalPagesCount = Math.ceil(data.totalCount/MAX_PAGE);
 const router = useRouter();
   const handlePageChange = (event: React.ChangeEvent<unknown>, page: number) => {
       let {pathname} = router;//現在のドメイン以降のパスを取得(パラメータは取得できない)
       router.query.page=page:
       router.push({
           pathname:pathname,
           query:router.query
       })
   }
  return(
     <>
       <Pagination 
    onChange={handlePageChange}//追加
        count={totalPagesCount} 
        page={page} 
        siblingCount={3} />
     </>
    )
}

useRouter()から取得できるpathNameは、ドメイン以降のパスを取得できますが、パラメータは取得できません
たとえば、category/blog/page/1のページで取得できるpathNameは”category/[categoryId]/page/[page]”となります。

よって、ページ遷移をしたいのであれば、router.queryを書き換えるのがよさそうです。

最後に、無理やり一覧画面を作るとすればこんな感じです。

const CategoryPage:NextPage<Props> =({categoryName,data,page,categoryId})=>{
 const totalPagesCount = Math.ceil(data.totalCount/MAX_PAGE);
  const articles = datas.contents;
 const router = useRouter();
   const handlePageChange = (event: React.ChangeEvent<unknown>, page: number) => {
       let {pathname} = router;//現在のドメイン以降のパスを取得(パラメータは取得できない)
       router.query.page=page:
       router.push({
           pathname:pathname,
           query:router.query
       })
   }
  return(
     <>
     <h1>{categoryName}</h1>
        <ul>
          {articles.map(article=>
             <li>{article.title}</li>
           )}
         </ul>
         <Pagination 
          onChange={handlePageChange}
              count={totalPagesCount} 
              page={page} 
              siblingCount={3} />
         </>
          )
      }



後は見た目のみですね!スタイルは適当に当てましょう。(本ブログはChakraUIを使ってスタイリングしています)

まとめ

今回はNextJs+MicroCMSのブログでの、カテゴリ別記事一覧の実装方法について説明しました。
ただのカテゴリ別一覧なのに、意外と骨が折れる工程でしたね、、
まだまだ改善の余地があると思うので、ご自身で実装する際は自分なりに工夫してみてください!

関連記事

Yakan

田舎育ちのアナログ人間ですが、システム作ってます。
趣味の制作はWebアプリ中心です。
仕事は業務系なのでC#メインで書いてます。

Yakan

田舎育ちのアナログ人間ですが、システム作ってます。
趣味の制作はWebアプリ中心です。
仕事は業務系なのでC#メインで書いてます。

© 2022 - 2023 Yakan.