本記事のゴール

Next.js製プロジェクトである、このブログにページ送り処理を追加し、ユーザーが古い記事を見るためにページ送りができるようにする。

作業

このブログは、unix.bioをもとに色々と改造を施しているのですが、ブログに絶対必要なページ送り が元のライブラリの状態ではありませんでした。

それでは物足りないので、ページ送りの追加をしてみます。unix.bioだけでなく、Markdown記事をmetadataとして生成する他のフレームワーク、例えばTailwind Nextjs Starter Blogなどでも 参考になれば幸いです。

要件定義

マークダウンファイルである、.mdx形式でブログ記事が書かれているため、ページを送るたびにajaxで都度、サーバサイドに結果を取得しにいくような実装ではなく、json形式のmetadataファイルから内容を一気に読み込み、ページごとに分割するようにします。

この読み方ですと、コンテンツ数・ページ数がめちゃくちゃ増えるとパフォーマンスの面で破綻する気がしますが一旦気にしないことにしましょう...。

設計

具体的に設計を進めます。unix.bioでは、Next13を採用しているものの、現在推奨されているAppレイアウトではなく、Pageレイアウトなので その点はご了承ください。(本記事も参考になるとは思います...)

なお、Pageレイアウトについては以下の公式ドキュメントのページで解説されています。

Pages and Layouts


設計を検討した結果、以下のように進めます。

実装を進める

結論から言うと、実際にページ送りを行う処理は、Reactで動くフロントエンドフレームワークライブラリMaterialUIPagination というライブラリにお願いしちゃおう と判断しました。ページャーを自前で作ると、

といった処理を全て自前でCSS実装しなければいけないので大変だと思いました...。

下記の参考記事等を見て、外部ライブラリに頼ってしまうのが結果早いと判断した次第でした。参考にさせていただきありがとうございました。

Next.js+Material-UIのPaginationでリストを作る
Next.js / MUI (Material UI) でページネーション作成

着地点のページファイル [pageNum].tsx

ページ送り毎の処理をブラウザから最初に受け取るページファイルは以下のようになります。先述の設計方針に沿って、[pageNum].tsxという名称で作りました。

/pages/page/[pageNum].tsx
import React from 'react'
import { Layout, Posts } from 'lib/components'
import { useRouter } from "next/router"
import { Loading } from '@geist-ui/core'
const Page: React.FC<unknown> = () => {
  const router = useRouter()

  const page = Array.isArray(router.query.pageNum) ? router.query.pageNum[0] : router.query.pageNum;

  if (page == undefined) {
    return <Layout>
      <Loading />
    </Layout>
  }

  return (
    <Layout>
      <Posts page={Number(page)} router={router} />
    </Layout>
  )
}

export default Page

一覧画面の各行(記事へのリンク)を出力する実態 posts.tsx

<Posts>は、ページ送りの1ページ目のindex.tsxとこちらの双方から呼ばれているコンポーネント です。このPostsコンポーネントでは、pageが引数としてnullable になっており、指定しなくてもTypeScriptのエラーが出ないようになっています。

実際に改修した後のposts.tsxは以下になります。

/lib/posts/posts.tsx
import Head from 'next/head'
import React, { useMemo } from 'react'
import PostItem from './post-item'
import { Configs } from 'lib/utils'
import metadata from 'lib/data/metadata.json'
import { useTheme } from '@geist-ui/core'
import Pager from './pager'
import { NextRouter } from 'next/router'


const getPosts = (data: typeof metadata, page?: number) => {
  const postNode = data.find(item => item.name === 'posts');
  const posts = (postNode || {}).children || [];

  let filteredPosts = posts;

  // タグなどで更なる表示コンテンツの絞り込みをしたい場合は、
  // ここでfilteredPostsを改変してください。

  let nowPage = page ? page : 1;

  const start = (nowPage - 1) * Configs.latestLimit;
  const end = nowPage * Configs.latestLimit;

  const postCount = filteredPosts.length; // ポストの総数を計算

  const paginatedPosts = filteredPosts.slice(start, end); // 開始位置・終了位置を指定して、配列を切り取る。

  return { postCount, posts: paginatedPosts };
};

export interface PostsProps {
  page?: number
  router: NextRouter
}

const Posts: React.FC<PostsProps> = ({ page,  router }) => {

  const theme = useTheme()
  let title = ""
  let { postCount, posts } = useMemo(() => getPosts(metadata, page), [page]);

  return (
    <section>
      <Head>
        <title>
          {title}
        </title>
      </Head>
      {title !== "" && <h2 className="mb-8">{title}</h2>}
      <div className="content">
        {
          // PostItemを呼び出すところは、unix.bioのまま、変更なし。
          posts.map((post, index) => (
          <PostItem post={post} key={`${post.url}-${index}`} />
        ))}
        {/* 下記Pagerコンポーネントへの呼び出し処理を新規に追加しています。 */}
        <Pager postCount={postCount} page={page} router={router} />
        {/*
        // unix.bioでは下記の「もっと読む」形式のリンクが備わっていましたが、SEO的に弱いため廃止。
        isLatest && <span className="more">{getMoreLink(posts.length)}</span>*/}
      </div>
      <style jsx>{`
        section {
          margin-top: calc(${theme.layout.gap} * 2);
        }

        .content {
          margin: ${theme.layout.gap} 0;
        }

        @media only screen and (max-width: ${theme.layout.breakpointMobile}) {
          section {
            margin-top: ${theme.layout.gapQuarter};
          }

          section h2 {
            margin-top: calc(1.5 * ${theme.layout.gap});
          }
        }
      `}</style>
    </section>
  )
}

export default Posts

ソースコード内にあります通り、ページングに必要な材料を組み立てて、さらに<Pager>コンポーネントに処理を渡しています。

リクエストを処理する、表層的なページング層 pager.tsx

<Pager>の中身では、Material UIのPaginationライブラリから受け取ったコールバックをもとに、実際のNext.jsレベルでの 遷移を実現しています。したがって、ページング処理の中でもあくまで表層的なことをしているコンポーネントとなりますが、処理には欠かせないものとなります。

ではpager.tsxを見てみます。

/lib/posts/posts.tsx
import * as React from 'react';
import Pagination from '@mui/material/Pagination';
import { NextRouter } from 'next/router';
import { Configs } from 'lib/utils';

type Prop = {
  router: NextRouter
  postCount: number
  page?: number
}

const Pager: React.FC<Prop> = ({ router, postCount,  page }) => {

  // 全記事数を1ページあたりの記事数で割って、ページの総数を生成して、MaterialUIのコンポーネントに渡している。
  const pageCount = Math.ceil(postCount / Configs.latestLimit)

  const handleChange = (_: React.ChangeEvent<unknown>, value: number) => {
    router.push(process.env.baseUrl + "/page/" + value)
  };

  if (page == undefined) {
    // ここ、routerからpage番号が取れていない時を考慮してreturnを実装しているが、
    // Loading表示を出すなど改修がもう少しできるかも?
    return <></> 
  }

  return (
    <div className="mt-12">
      <Pagination defaultPage={page} count={pageCount} variant="outlined" shape="rounded" color="primary" onChange={handleChange}
      />
    </div>
  );
}

export default Pager

Material Paginationについては、こちらをご覧ください。今回私はRounded paginationを少々cssカスタマイズする形で使用しています。global.cssにページング周りのCSSを追加しています。この辺りはお好みに合わせてどうぞ。

paging.gif
出来上がりとしてはこちらです。

ソースコード中にあったConfigsについて一応解説しておきます。

1ページあたりの件数など指定。utils.ts & blog.config

<Pager>および<Posts>Configsという定数クラスを呼んでいましたが、これはutils.ts=>blog.configという2ファイルに遡って定義されています。(unix.bioのデフォルトでそうなっています)

こちらは実際にソースコードを見ていただくと分かるかと思いますので省略します。(究極、ソースコード内にハードコーディングでも個人レベルのブログという用途を考えると 大きな問題はないはずです)

まとめ

改めて自前でPagingを作るのって大変だな と思いました...。
でもみんなこの道を通ってきているんですよね。


一方でサイトの利用者であるエンドユーザーは何で作られているかはあまり気にならない方が多いと思うので、外部のライブラリに頼れるところはどんどん頼って、いいコンテンツを作るところに集中できればなあといったように改めて思いました!


この記事が何かのお役に立てれば幸いです。
最後までお読みいただきありがとうございました!