🐥

Next.js × Contentlayerでマークダウンのブログを作ろう

2024/02/06に公開

はじめに

Next.jsで作成したサービスサイトに、ブログ形式のお知らせページを実装したい。
更新頻度はそこまで高くないので、microCMSなどのヘッドレスCMSは利用せず、まずはプロジェクト内で直接マークダウンを管理することを目指す。
そこで、Next.jsにおけるマークダウンの利用方法を調べたところ、Contentlayerが適切だった。

しかしながら、公式サイトのGetting Startedが現在の環境では動かなかったため、備忘録として導入手順とエラー対処方法を記録する。
https://contentlayer.dev/docs/getting-started-cddd76b7#nextjs-configuration

本記事では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の設定

devbuildの際にContentlayerを実行するための設定。
私の環境だとプロジェクト作成時に生成されたnext.configの拡張子がmjsだったので、公式サイトのコードで利用されているrequiremodule.exportsが使えなかった。
代わりにimportexport dafaultを使う。
(なお拡張子を手動で.jsに書き換えると元々のコードで正常に動作する。)

next.config.mjs
// 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に書き換えた方が良いかもしれない。

https://blog.stin.ink/articles/introduce-contentlayer

TypeScriptの設定

"baseUrl": ".","contentlayer/generated": ["./.contentlayer/generated"]".contentlayer/generated"の3つを公式サイトの内容に従って追加する。

tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    //  ^^^^^^^^^^^
    "paths": {
      "contentlayer/generated": ["./.contentlayer/generated"]
      // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".contentlayer/generated"
    // ^^^^^^^^^^^^^^^^^^^^^^
  ]
}

私の環境では以下のようになった。

tsconfig.json
{
  "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をプロジェクト直下に作成する。

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フォルダの中にマークダウンで格納される。
    • titledateの要素を必ず持つ。
    • urlはresolve関数を使って、その投稿のURLを生成する。
    • bodyフィールドにHTMLがある。

デモ用のマークダウンファイル(Post Content)を作成

postsフォルダをプロジェクト直下に作成する。
その中にposts/post-01.mdのような形でデモ用のファイルを作成する。

post-01.md
---
title: My First Post
date: 2021-12-24
---

Ullamco et nostrud magna commodo nostrud ...
post-02.md
---
title: My Second Post
date: 2021-12-25
---

Ullamco et nostrud magna commodo nostrud ...
post-03.md
---
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した際に生成されるためだ。

app/page.tsx
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フォルダではないので注意。

app/posts/[slug]/page.tsx
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

完成

このように記事のタイトルをクリックして詳細ページに飛べるようになる。

参考

https://contentlayer.dev/docs/getting-started-cddd76b7
https://blog.stin.ink/articles/introduce-contentlayer

Discussion