no7.space
😺

Next.js でブログを作る:記事一覧ページ

前回は記事の詳細を表示できるようになったので、今度は一覧を作ります。
一覧と詳細が出せれば、とりあえず記事を書いて公開できるようになるので。

SSG で一覧ページ

一覧ページ用の実装って通常なら1ページで動的にやってあげれば良さそうに見えますが、SSGだとそうも行きません。
当然ながら送られるページ全部を書き出しておく必要があります。

URL的にはこんな感じでしょうか

blog/page/1

なので、これにあわせてルートを作成します

blog/page/[page]/page.tsx

なんか page の亜種がいっぱい並んだパスになってしまいましたね 💦
(なお、設計のときはこれでしたが最終的にはURL上は p だけにすることにしました。)

記事一覧を取得するユーティリティの作成

まずは指定したディレクトリ内のファイルを読み取って必要な情報を返すやつです

import fs from 'node:fs/promises'
import path from 'node:path'
import matter from 'gray-matter'
import type { PostMetadata } from '@/types/post'

const contentDirRoot = path.join(process.cwd(), 'content')

type Props = {
  postType: string
}

export async function getAllPostsMetadata({
  postType = 'blog',
}: Props): Promise<PostMetadata[]> {
  const contentDir = path.join(contentDirRoot, postType)
  const filenames = await fs.readdir(contentDir)

  const posts: PostMetadata[] = []

  for (const filename of filenames) {
    if (!filename.endsWith('.md')) continue

    const slug = filename.replace(/\.md?$/, '')
    const filePath = path.join(contentDir, filename)
    const source = await fs.readFile(filePath, 'utf8')
    const { data } = matter(source)

    posts.push({
      title: data.title ?? slug,
      created: data.created,
      updated: data.updated ?? data.created,
      ulid: data.ulid,
      description: data.description ?? '',
      tags: data.tags ?? [],
    })
  }

  // created でソートして返す
  return posts.sort((a, b) => {
    const dateA = a.created ? new Date(a.created).getTime() : 0
    const dateB = b.created ? new Date(b.created).getTime() : 0
    return dateB - dateA
  })
}

つぎに、これを利用して、現在のページを表示するのに必要な分と、ページ送りに必要な情報だけを返すやつを作りました。

import { getAllPostsMetadata } from '@/lib/posts'
import { blogPostsPerPage } from '@/site.config'

export async function getPagedPosts(page: number) {
  const PER_PAGE = blogPostsPerPage || 10
  const posts = await getAllPostsMetadata({ postType: 'blog' })
  const start = (page - 1) * PER_PAGE
  const end = start + PER_PAGE
  const pagedPosts = posts.slice(start, end)
  const totalPages = Math.ceil(posts.length / PER_PAGE)
  return {
    pagedPosts,
    total: posts.length,
    perPage: PER_PAGE,
    totalPages,
  }
}

Note

なんで処理が別々になってるのかは、開発中の歴史的経緯というやつです。
いろいろ落ち着いたらリファクタしよう……

表示側の作成

import { notFound, redirect } from 'next/navigation'
import BlogPostList from '@/components/BlogPostList'
import { getPagedPosts } from '@/lib/paged-posts'

import styles from '../../page.module.scss'

export const dynamic = 'force-static'

type Props = {
  params: Promise<{ paged: string }>
}

export async function generateStaticParams() {
  const { totalPages } = await getPagedPosts(1)
  return Array.from({ length: totalPages }, (_, i) => ({
    paged: (i + 1).toString(),
  }))
}

export default async function pagedPostList({ params }: Props) {
  const { paged } = await params
  if (paged === '1') {
    redirect('/blog')
  }
  const page = paged ? parseInt(paged, 10) : 1
  const { pagedPosts, perPage, totalPages } = await getPagedPosts(page)

  if (pagedPosts.length === 0 || page > totalPages) {
    return notFound()
  }
  return (
    <main className={styles.main}>
      <BlogPostList pagedPosts={pagedPosts} page={page} perPage={perPage} />
    </main>
  )
}

これで、getPagedPosts で生成したリストから一覧を生成しています。
一覧生成部分を更にコンポーネントに分けてる理由はこのあと話します。

1ページ目のときの処理

ところで、一覧をページ送りしていく時って、1ページ目は /blog とかで、2ページ目以降になると /blog/page/2 とかになるのがよく見る構成だとおもいます。
(ついでに、いまのままだと /blog に直に来たときに表示するものがないのでなんかださないとなーみたいなのも)

なので、1ページ目のときは /blog にリダイレクトするようにしようとおもいます。
やり方はもうコードに書いちゃってありますが、

if (paged === '1') {
  redirect('/blog')
}

だけでよさげでした。

で、/blog にアクセスしたときの処理は

blog/page.tsx

に書きます。
中身はほぼ一緒で、ロジックも、スタイルも、表示部分も共通でOKそうです。

ということで、こっちでも使いたかったので切り出したというわけでした。

これで /blog で一覧1ページ目、ソレ以降は /blog/p/2/blog/p/3 ... というふうにできるようになりました