Next.js

Next.jsとContentLayerを使ってMDXブログを作る

Next.js App RouterとContentLayer2を使用してMDXブログを作る手順をまとめました。

Next.jsとContentLayerを使ってMDXブログを作る

コンニチハ!ハルミです。

今回は、Next.js App RouterContentLayerを使用して MDX ブログを作る手順をまとめました。 この記事では

  • Next.js v14.2.8
  • TypeScript v5
  • Tailwind CSS v3.4.1

を使用していますが、これらの使い方や導入手順については
この記事では解説しませんので、あらかじめご了承ください。

はじめに

当記事では、Next.js App RouterContentLayerを使用して MDX ブログを作る手順をまとめています。

当サイト作成時や、この記事を書くにあたって
以下のサイトを参考にさせていただきました。

shadcn/uiで有名なshadcnさんという方が GitHub で公開しているリポジトリです。

当記事では、こちらのリポジトリを参考にしながら
自分が躓いた点を中心に解説していきます。

読み込み中...

ContentLayer とは

ContentLayerは、Markdown ファイルをデータベースとして使用して、ブログやドキュメントを作成するためのフレームワークです。
Markdown 形式のデータを TypeScript で扱える型付きの JSON として変換し、これにより開発者はコンテンツの整合性を保ちながら、簡単にデータを操作できます。

こちらを使う主なメリットは以下の通りです。

  • 型安全性
  • フロントマターの処理
  • HMR(Hot Module Replacement)に対応
  • React コンポーネントを埋め込むことができる

型安全性

ContentLayerは、Markdown ファイルの内容を基にTypeScript の型定義を自動生成します。これにより、開発者はコンパイル時にエラーを検出でき、より安全なコードを書くことが可能です。

フロントマターの処理

Markdown ファイル内のメタデータ(フロントマター)を解析し、必要な情報を抽出して JSON 形式で提供します。これにより、記事のタイトルや作成日などの情報を簡単に管理できます。
フロントマターについては後ほど導入の際に解説します。

HMR(Hot Module Replacement)に対応

HMR は、モジュールを更新した際に、ページのリロードを行わずにモジュールを更新することができる機能です。
ContentLayerは、Markdown ファイルの変更をリアルタイムで検知し、自動的に更新する機能を持っています。

つまり、

開発環境で実際の記事の画面を確認しながらマークダウンを書く様子

このように、エディターでMarkdownで記事を書きながら
実際の記事の画面をリアルタイムで確認する
ことができます。 これはWordPressなどのCMSでは実現できないことです。
実際にかなり快適に記事執筆ができています。

React コンポーネントを埋め込むことができる

ContentLayerは、React コンポーネントを埋め込むことができます。これにより、開発者はより柔軟にコンテンツを表示することができます。

ContentLayer2 の導入

まず初めにお伝えしたいのですが、
ContentLayerは 2024 年 9 月現在、Next.js 14.2.8 App Routerでは使用できません。

具体的には、Netlifyがこのプロジェクトを支援していた企業を買収したことにより、資金提供が停止されました。その結果、ContentLayer は 2023 年 8 月以降更新されておらず、現在の状態ではNext.js 14との依存関係の衝突により使用できない状況です。

読み込み中...

そのため、この当サイトでは有志によってフォークされたContentLayer2を使用しています。

読み込み中...

Next.jsのセットアップ

まずはNext.jsのプロジェクトを作成します。 Next.jsの導入についてはこの記事では割愛します。
TypeScriptは使用します。

npx create-next-app@latest

パッケージのインストール

それではセットアップしていきましょう。
まずはパッケージのインストールをします。

npm i contentlayer2 next-contentlayer2

tsconfig.json に設定を追加

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

.gitignore に設定を追加

.gitignore
# contentlayer
.contentlayer

データ型の定義

次に、contentlayer.config.tsを作成します。

contentlayer.config.ts
import {
  defineDocumentType,
  makeSource,
} from "contentlayer2/source-files";

// ブログの記事を管理するためのデータ型を定義
export const Post = defineDocumentType(() => ({
  name: 'Post',
  // ブログの記事のパスを指定(ここではblogディレクトリ配下のmdxファイルを対象としている)
  filePathPattern: `blog/**/*.mdx`,
  contentType: 'mdx',
  fields: {
    title: { type: 'string', required: true },
    description: { type: 'string' },
    date: { type: 'date', required: true },
    published: {type: 'boolean' , default: true},
    image: { type: 'string', required: true },
  },
  // 記事のURLを生成するための計算フィールド
  // 以下のように書くと、記事のパスが/blog/[記事のファイル名]になる
  computedFields: {
    slug: { type: 'string', resolve: (doc) => `/${doc._raw.flattenedPath}` },
  },
}))

// ソースディレクトリを指定してmakeSource関数を呼び出す
export default makeSource({
  contentDirPath: "./content",
  documentTypes: [Post],
});

少しだけ解説します。

filePathPatternでは、ブログの記事のパスを指定しています。ここではblogディレクトリ配下のmdxファイルを対象としています。以下で説明するmakeSource関数で指定したcontentDirPathで指定したディレクトリの配下に作成されます。
上記のコードではcontent/blog配下に記事を配置していくことになります。

filePathPattern: `blog/**/*.mdx`,

fieldsでは、ブログの記事のフィールドを指定しています。ここではtitledescriptiondatepublishedimageを指定しています。

fields: {
  title: { type: 'string', required: true },//記事のタイトル
  description: { type: 'string' },//記事の説明
  date: { type: 'date', required: true },//記事の投稿日
  published: {type: 'boolean' , default: true},//記事の公開状態
  image: { type: 'string', required: true },//記事の画像
},

computedFieldsでは、記事の URL を生成するための計算フィールドを指定しています。ここではslugにファイル名を指定しています。

computedFields: {
  slug: { type: 'string', resolve: (doc) => `/${doc._raw.flattenedPath}` },
},

makeSource関数を使用して、ソースディレクトリを指定しています。ここではcontentディレクトリを指定しています。こうするとプロジェクトのルート配下にcontentディレクトリが作成されます。

documentTypesでは、ブログの記事のデータ型を指定しています。ここで先ほど定義したPostを指定しています。

export default makeSource({
  contentDirPath: "./content",
  documentTypes: [Post],
});

データ型のネストについて

fieldsで、Postのデータ型を定義していました。ここではtitledescriptiondatepublishedimageを指定していました。

では、ここにcategoryというフィールドを追加したいとします。 categoryにはカテゴリー名URL 用の slugを指定したいと思います。

import {
  defineDocumentType,
+ defineNestedType,
  makeSource,
} from "contentlayer2/source-files";

~~~~~

+ export const Category = defineNestedType(() => ({
+   name: "Category",
+   fields: {
+     name: { type: "string", required: true },
+     slug: { type: "string", required: true },
+   },
}));

~~~~~

  fields: {
    title: { type: 'string', required: true },
    description: { type: 'string' },
    date: { type: 'date', required: true },
    published: {type: 'boolean' , default: true},
    image: { type: 'string', required: true },
+     category: {
+     type: "nested",
+     required: true,
+     of: Category,
+   },
  },

~~~~~

解説です。
まず、defineNestedType関数が用意されているのでインポートします。

import { defineNestedType } from "contentlayer2/source-files";

次に、defineNestedType関数を使用してCategoryというデータ型を定義します。

export const Category = defineNestedType(() => ({
  name: "Category",
  fields: {
    name: { type: "string", required: true },
    slug: { type: "string", required: true },
  },
}));

これでCategoryというデータ型が定義されました。
このCategoryPostのデータ型にネストします。

fields: {
  title: { type: 'string', required: true },
  description: { type: 'string' },
  date: { type: 'date', required: true },
  published: {type: 'boolean' , default: true},
  image: { type: 'string', required: true },
  category: {
     type: "nested",//ネストするデータ型を指定
     required: true,//必須にする
     of: Category,//先ほど定義したCategory型を指定
   },
},

これでCategoryというデータ型がPostのデータ型にネストされました。
型をネストする場合は、専用の関数があるので注意しましょう。

next.config.mjs に設定を追加

次に、next.config.mjsに設定を追加します。

next.config.mjs
import { withContentlayer } from "next-contentlayer2";

const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,
};

export default withContentlayer(nextConfig);

withContentlayer関数を使用して、ContentLayerの設定を追加します。 設定についてはこれだけです。

package.json にビルドコマンドを追加

package.json
"scripts": {
  "build:content": "contentlayer2 build"
}

以上でContentLayerの基本設定は完了です。 上記ビルドコマンドを実行しなくても
npm run devを実行した際に自動的にビルドされると思います。

MDX で記事を作成する

基本設定が終わったので、MDX で記事を作成していきましょう。 先ほど設定したcontent/blog配下にexample.mdxというファイルを作成します。

/
content
└─ blog
   └─ example.mdx
example.mdx
---
title: 記事のタイトル
description: 記事の説明
date: 2024-09-26
published: true
image: /images/blog/example.webp
category:
  name: ContentLayer
  slug: contentlayer
---

~記事の本文~

"---"で始まる行をFrontmatterと呼びます。
ここでは、記事のタイトル、記事の説明、記事の投稿日、記事の公開状態、記事の画像、カテゴリーを指定しています。

contentlayer.config.tsで定義したPostのデータ型に従って入力していきます。
入力した型が間違っているとエラーが出るので安心ですね。
required を true にしているものは必須項目になります。

記事ページを作成する

ようやく記事ページを作成していきます。
contentlayer にはallPostsという便利な関数が用意されていて
これを実行するとcontent/blog配下のmdxファイルが全て取得できます。

以下は簡単な例です。

page.tsx
import Link from 'next/link'
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>
      //記事の本文
      <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>
  )
}

post.titleのようにFrontmatterで指定したデータにアクセスできます。

また、本文の出力ですが、

<div dangerouslySetInnerHTML={{ __html: post.body.html }} />

このように書けば本文を出力できます。 あとは CSS でスタイリングしていくだけです。

しかし、この書き方はやや欠点があるので、次のように変更しましょう。

記事内に React コンポーネントを埋め込む

上記で記事は表示できましたが、実はこれでは React コンポーネントを埋め込むことができません。
なので、変更していきましょう。

まず、contentlayer.config.tscontentTypemdxになっていることを確認しましょう。

contentlayer.config.ts
export const Post = defineDocumentType(() => ({
  name: 'Post',
  filePathPattern: `blog/**/*.mdx`,
- contentType: 'md',
+ contentType: 'mdx',
}));

次に MDX でコンポーネントを作成していきます。
ContentLayer には useMDXComponent という便利な関数が用意されていて
これを使用すると MDX でコンポーネントを埋め込むことができます。

使用したいコンポーネントがあれば、useMDXComponentで作成したコンポーネントにcomponentsで渡してあげましょう。

components/MDX.tsx
import { useMDXComponent } from 'next-contentlayer/hooks'
import Image from 'next/image'

export default function MDX({ code }: { code: string }) {
  const MDXContent = useMDXComponent(code)

  const components = {
    Image,
  }

  return (
    <div>
      {/* Some code ... */}
      <MDXContent components={components} />
    </div>
  )
}

あとはこのコンポーネントをpage.tsxで使用します。
MDX コンポーネントにpost.body.codeを渡しましょう。

page.tsx
import MDX from '@/components/MDX'

~~~~~

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>
      //post.body.codeにはMDXのコードが入っている
      <MDX code={post.body.code} />
    </div>
  )
}

~~~~~

これで React コンポーネントを埋め込むことができました。
MDX のスタイリングについては、この記事では触れません。

以上で ContentLayer の基本的な使い方は終了です。

公式ドキュメントでも詳しく解説されているので
こちらも参考にしてみてください。

読み込み中...

終わりに

今回は、Next.js App RouterContentLayerを使用して MDX ブログを作る手順をまとめました。 いかがだったでしょうか。

正直、MDX ブログを作るならAstroの方が作りやすいかもしれません。

Next.js で MDX ブログを作りたいんだ!という方にはオススメです。

フレームワークはともかく、MDX と SSG を組み合わせれば
WordPress などの CMS とは次元の違うパフォーマンスの
ブログサイトを作ることができるので、
爆速なブログサイトを作りたい方はぜひ試してみてください ☺

profile

ハルミ

1997年生まれ。某メーカーの新米DX担当。
三度の飯より効率化が好き。
プログラミングにハマり、Webエンジニアを目指す。
現在React/Next.jsを学習しています🚀