コンニチハ!ハルミです。
今回は、Next.js App RouterとContentLayerを使用して MDX ブログを作る手順をまとめました。 この記事では
- Next.js v14.2.8
- TypeScript v5
- Tailwind CSS v3.4.1
を使用していますが、これらの使い方や導入手順については
この記事では解説しませんので、あらかじめご了承ください。
はじめに
当記事では、Next.js App RouterとContentLayerを使用して 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 に設定を追加
{
"compilerOptions": {
"baseUrl": ".",
// ^^^^^^^^^^^
"paths": {
"contentlayer/generated": ["./.contentlayer/generated"]
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".contentlayer/generated"
// ^^^^^^^^^^^^^^^^^^^^^^
]
}
.gitignore に設定を追加
# contentlayer
.contentlayer
データ型の定義
次に、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では、ブログの記事のフィールドを指定しています。ここではtitle、description、date、published、imageを指定しています。
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のデータ型を定義していました。ここではtitle、description、date、published、imageを指定していました。
では、ここに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というデータ型が定義されました。
このCategoryをPostのデータ型にネストします。
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に設定を追加します。
import { withContentlayer } from "next-contentlayer2";
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
};
export default withContentlayer(nextConfig);
withContentlayer関数を使用して、ContentLayerの設定を追加します。 設定についてはこれだけです。
package.json にビルドコマンドを追加
"scripts": {
"build:content": "contentlayer2 build"
}
以上でContentLayerの基本設定は完了です。
上記ビルドコマンドを実行しなくても
npm run devを実行した際に自動的にビルドされると思います。
MDX で記事を作成する
基本設定が終わったので、MDX で記事を作成していきましょう。 先ほど設定したcontent/blog配下にexample.mdxというファイルを作成します。
/
content
└─ blog
└─ 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ファイルが全て取得できます。
以下は簡単な例です。
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.tsのcontentTypeがmdxになっていることを確認しましょう。
export const Post = defineDocumentType(() => ({
name: 'Post',
filePathPattern: `blog/**/*.mdx`,
- contentType: 'md',
+ contentType: 'mdx',
}));
次に MDX でコンポーネントを作成していきます。
ContentLayer には useMDXComponent という便利な関数が用意されていて
これを使用すると MDX でコンポーネントを埋め込むことができます。
使用したいコンポーネントがあれば、useMDXComponentで作成したコンポーネントにcomponentsで渡してあげましょう。
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を渡しましょう。
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 RouterとContentLayerを使用して MDX ブログを作る手順をまとめました。 いかがだったでしょうか。
正直、MDX ブログを作るならAstroの方が作りやすいかもしれません。
Next.js で MDX ブログを作りたいんだ!という方にはオススメです。
フレームワークはともかく、MDX と SSG を組み合わせれば
WordPress などの CMS とは次元の違うパフォーマンスの
ブログサイトを作ることができるので、
爆速なブログサイトを作りたい方はぜひ試してみてください ☺