Next.js × Contentlayerでマークダウンのブログを作ろう
はじめに
Next.jsで作成したサービスサイトに、ブログ形式のお知らせページを実装したい。
更新頻度はそこまで高くないので、microCMSなどのヘッドレスCMSは利用せず、まずはプロジェクト内で直接マークダウンを管理することを目指す。
そこで、Next.jsにおけるマークダウンの利用方法を調べたところ、Contentlayerが適切だった。
しかしながら、公式サイトのGetting Startedが現在の環境では動かなかったため、備忘録として導入手順とエラー対処方法を記録する。
本記事ではGetting Startedを実施するところまでしか記載がないが、また別の記事にてフォルダ構成やページデザインの変更などに取り組む予定である。
導入手順
公式サイトのGetting Startedを簡単に翻訳する形で進める。
1.プロジェクトのセットアップ
プロジェクトの作成
まずは以下のコードををターミナルに入力してプロジェクトを作成する。
npx create-next-app@13.5.4 --typescript --tailwind --experimental-app --eslint contentlayer-example
公式サイトではnpx create-next-app@latest
で作成しているが、現在最新のNext.jsのバージョンである14だとContentlayerが動作しない。(2024/02/06時点)
その場合はバージョンを下げる必要がある。
そのため最初からバージョンを指定した方が良い。
なお、今回13.5.4
を指定したのは、私が導入予定の本番環境に合わせたためである。
Next.jsのバージョンの下げ方
npm uninstall next
npm install next@13.5.4
次に作成したフォルダに入る。
cd contentlayer-example
依存関係のインストール
必要なプラグインをインストールする。なお、date-fns
は日付の表示に使うだけなので、Contentlayerとは直接関係がない。
npm install contentlayer next-contentlayer date-fns
Next.jsの設定
dev
とbuild
の際にContentlayerを実行するための設定。
私の環境だとプロジェクト作成時に生成されたnext.configの拡張子がmjs
だったので、公式サイトのコードで利用されているrequire
とmodule.exports
が使えなかった。
代わりにimport
とexport dafault
を使う。
(なお拡張子を手動で.js
に書き換えると元々のコードで正常に動作する。)
// const { withContentlayer } = require("next-contentlayer");
import { withContentlayer } from "next-contentlayer";
/** @type {import('next').NextConfig} */
const nextConfig = { reactStrictMode: true, swcMinify: true };
// module.exports = withContentlayer(nextConfig);
export default withContentlayer(nextConfig);
ChatGPTでの回答
file:///Users/abcd/development/nextjs_pj/contentlayer-example/next.config.mjs:1
const { withContentlayer } = require("next-contentlayer");
^
ReferenceError: require is not defined in ES module scope, you can use import instead
at file:///Users/abcd/development/nextjs_pj/contentlayer-example/next.config.mjs:1:30
エラーメッセージに遭遇しているのは、ECMAScriptモジュール(.mjsファイル)でCommonJS構文(require)を使用しているためです。ECMAScriptモジュールは、.mjsファイル拡張子を使用するか、package.jsonに"type": "module"を設定することで示されます。ECMAScriptモジュールでは、requireの代わりにimport構文を使用する必要があります。
module.exportsはCommonJSの構文であり、ESモジュールではexport defaultを使用してモジュールをエクスポートする必要があります。
next.config.mjsファイルをESモジュールの構文に完全に合わせるためには、module.exportsの代わりにexport defaultを使用して設定をエクスポートするように変更する必要があります。
(追記)
next.config.mjs
だとWebpackに関する警告が大量に出るため、.mjs
が必要ないのであれば.js
に書き換えた方が良いかもしれない。
TypeScriptの設定
"baseUrl": ".",
・"contentlayer/generated": ["./.contentlayer/generated"]
・".contentlayer/generated"
の3つを公式サイトの内容に従って追加する。
{
"compilerOptions": {
"baseUrl": ".",
// ^^^^^^^^^^^
"paths": {
"contentlayer/generated": ["./.contentlayer/generated"]
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".contentlayer/generated"
// ^^^^^^^^^^^^^^^^^^^^^^
]
}
私の環境では以下のようになった。
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"contentlayer/generated": ["./.contentlayer/generated"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".contentlayer/generated"
],
"exclude": ["node_modules"]
}
gitognoreの設定
ビルド時に生成された.contentlayer
ディレクトリがgitに含まれないように設定する。
# .gitignore
# ...
# contentlayer
.contentlayer
2.コンテンツのスキーマを定義
Contentlayerの設定を追加
新たにcontentlayer.config.ts
をプロジェクト直下に作成する。
import { defineDocumentType, makeSource } from 'contentlayer/source-files'
export const Post = defineDocumentType(() => ({
name: 'Post',
filePathPattern: `**/*.md`,
fields: {
title: { type: 'string', required: true },
date: { type: 'date', required: true },
},
computedFields: {
url: { type: 'string', resolve: (post) => `/posts/${post._raw.flattenedPath}` },
},
}))
export default makeSource({ contentDirPath: 'posts', documentTypes: [Post] })
やっていること
-
Post
と呼ばれるドキュメント型を定義-
posts
フォルダの中にマークダウンで格納される。 -
title
とdate
の要素を必ず持つ。 -
url
はresolve関数を使って、その投稿のURLを生成する。 -
body
フィールドにHTMLがある。
-
デモ用のマークダウンファイル(Post Content)を作成
postsフォルダをプロジェクト直下に作成する。
その中にposts/post-01.md
のような形でデモ用のファイルを作成する。
---
title: My First Post
date: 2021-12-24
---
Ullamco et nostrud magna commodo nostrud ...
---
title: My Second Post
date: 2021-12-25
---
Ullamco et nostrud magna commodo nostrud ...
---
title: My Third Post
date: 2021-12-26
---
Ullamco et nostrud magna commodo nostrud ...
posts/
├── post-01.md
├── post-02.md
└── post-03.md
3.サイト側のコードを追加する
一覧ページと記事詳細ページを作る。
ルートのページを一覧ページに書き換える
app/page.tsxを以下のコードに書き換える。
この段階ではcontentlayer/generated
にエラーが出るが問題ない。npm run dev
した際に生成されるためだ。
import Link from 'next/link'
import { compareDesc, format, parseISO } from 'date-fns'
import { allPosts, Post } from 'contentlayer/generated'
function PostCard(post: Post) {
return (
<div className="mb-8">
<h2 className="mb-1 text-xl">
<Link href={post.url} className="text-blue-700 hover:text-blue-900 dark:text-blue-400">
{post.title}
</Link>
</h2>
<time dateTime={post.date} className="mb-2 block text-xs text-gray-600">
{format(parseISO(post.date), 'LLLL d, yyyy')}
</time>
<div className="text-sm [&>*]:mb-3 [&>*:last-child]:mb-0" dangerouslySetInnerHTML={{ __html: post.body.html }} />
</div>
)
}
export default function Home() {
const posts = allPosts.sort((a, b) => compareDesc(new Date(a.date), new Date(b.date)))
return (
<div className="mx-auto max-w-xl py-8">
<h1 className="mb-8 text-center text-2xl font-black">Next.js + Contentlayer Example</h1>
{posts.map((post, idx) => (
<PostCard key={idx} {...post} />
))}
</div>
)
}
run dev して一覧ページを確認する
npm run dev
http://localhost:3000/
にアクセスすると、以下のような一覧ページが表示される。
なお、この段階では記事のタイトルをクリックして詳細ページに飛ぼうとすると404エラーが出る。
投稿の詳細ページを作成する。
以下の形でフォルダとpage.tsx
を作成する。
app/posts/[slug]/page.tsx
デモ用のマークダウンファイルを作成したposts
フォルダではないので注意。
import { format, parseISO } from 'date-fns'
import { allPosts } from 'contentlayer/generated'
export const generateStaticParams = async () => allPosts.map((post) => ({ slug: post._raw.flattenedPath }))
export const generateMetadata = ({ params }: { params: { slug: string } }) => {
const post = allPosts.find((post) => post._raw.flattenedPath === params.slug)
if (!post) throw new Error(`Post not found for slug: ${params.slug}`)
return { title: post.title }
}
const PostLayout = ({ params }: { params: { slug: string } }) => {
const post = allPosts.find((post) => post._raw.flattenedPath === params.slug)
if (!post) throw new Error(`Post not found for slug: ${params.slug}`)
return (
<article className="mx-auto max-w-xl py-8">
<div className="mb-8 text-center">
<time dateTime={post.date} className="mb-1 text-xs text-gray-600">
{format(parseISO(post.date), 'LLLL d, yyyy')}
</time>
<h1 className="text-3xl font-bold">{post.title}</h1>
</div>
<div className="[&>*]:mb-3 [&>*:last-child]:mb-0" dangerouslySetInnerHTML={{ __html: post.body.html }} />
</article>
)
}
export default PostLayout
完成
このように記事のタイトルをクリックして詳細ページに飛べるようになる。
参考
Discussion