
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
... というふうにできるようになりました