mdxで書いたテキストをNext.jsのページとして表示する

Next.js app routerで、mdxファイルに書いたコンテンツをページとして表示する方法が何通りかあり、実装しながらあれこれ悩んだので備忘録として記録しておく。

結論

gray-matterを利用してmdxで作成したコンテンツをパースし、frontmatterはjsのオブジェクトとして、コンテンツの内容は文字列として読み込む。

GitHub - jonschlinkert/gray-matter: Smarter YAML front matter parser, used by metalsmith, Gatsby, Netlify, Assemble, mapbox-gl, phenomic, vuejs vitepress, TinaCMS, Shopify Polaris, Ant Design, Astro, hashicorp, garden, slidev, saber, sourcegraph, and many others. Simple to use, and battle tested. Parses YAML by default but can also parse JSON Front Matter, Coffee Front Matter, TOML Front Matter, and has support for custom parsers. Please follow gray-matter's author: https://github.com/jonschlinkert

Smarter YAML front matter parser, used by metalsmith, Gatsby, Netlify, Assemble, mapbox-gl, phenomic, vuejs vitepress, TinaCMS, Shopify Polaris, Ant Design, Astro, hashicorp, garden, slidev, saber...

GitHub

コンテンツの文字列は@mdx-js/mdxを利用してJSXのコンポーネントに変換する。

GitHub - mdx-js/mdx: Markdown for the component era

Markdown for the component era. Contribute to mdx-js/mdx development by creating an account on GitHub.

GitHub

スタイリングは基本的に@tailwindcss/typographyに任せる。

GitHub - tailwindlabs/tailwindcss-typography: Beautiful typographic defaults for HTML you don't control.

Beautiful typographic defaults for HTML you don't control. - tailwindlabs/tailwindcss-typography

GitHub

remark-gfmを利用してGitHubのmarkdown記法を利用できるようにする。

GitHub - remarkjs/remark-gfm: remark plugin to support GFM (autolink literals, footnotes, strikethrough, tables, tasklists)

remark plugin to support GFM (autolink literals, footnotes, strikethrough, tables, tasklists) - remarkjs/remark-gfm

GitHub

コードブロックにシンタックスハイライトを適用するためにrehype-pretty-codeを利用する。

GitHub - rehype-pretty/rehype-pretty-code: Beautiful code blocks for Markdown or MDX.

Beautiful code blocks for Markdown or MDX. Contribute to rehype-pretty/rehype-pretty-code development by creating an account on GitHub.

GitHub

やりたいこと

  1. mdxファイルに書いたコンテンツをhtmlで表示したい
  2. ページの先頭から適当な長さの文字列を切り取って、metaタグのog:descriptionとjson-ldのdescriptionに設定したい

方法1:@next/mdxを利用(🙅不採用)

そもそもNext.jsでmdxを取り扱う方法はNext.jsの公式ドキュメントに載っており、ここではNext.js組み込みの@next/mdxを利用している。

Guides: MDX | Next.js

Learn how to configure MDX and use it in your Next.js apps.

nextjs.org

なのでこれに沿って実装するだけでやりたいことの1は実装できる。
ただしこの方法はmdxファイルを文字列として扱うことはできないので、やりたいことの2を実現するには別途他の方法でmdxファイルを読み込む必要がある。
まあ別途読み込めばいいんだけども、読み込む方法は1種類であった方がシンプルで良いと思うし、1記事ビルドするだけのソースの中にファイルを読み込む処理が2回あるのは不自然なので、今回は別のやり方にする。

方法2:next-mdx-remote-clientを利用(🙅不採用)

mdxファイルのコンテンツを文字列として読み込むだけなら、シンプルにnode組み込みのfsを使えば良い。
そして読み込んだ文字列をhtmlとして表示するには、Next.jsのドキュメントで紹介されているnext-mdx-remote-clientを利用すれば良い。

Guides: MDX | Next.js

Learn how to configure MDX and use it in your Next.js apps.

nextjs.org

GitHub - ipikuka/next-mdx-remote-client: A wrapper of `@mdx-js/mdx` for `Next.js` applications in order to load MDX content. It is a fork of `next-mdx-remote`.

A wrapper of `@mdx-js/mdx` for `Next.js` applications in order to load MDX content. It is a fork of `next-mdx-remote`. - ipikuka/next-mdx-remote-client

GitHub

ただし、next-mdx-remote-clientは本来CMSやDBで管理しているmarkdownコンテンツを扱うときに利用するライブラリなので、用途が少しズレている。
また、next-mdx-remote-clientの前身のライブラリであるnext-mdx-remoteのドキュメントには以下のようにも書いている。

You Might Not Need next-mdx-remote
If you're using React Server Components and just trying to use basic MDX with custom components, you don't need anything other than the core MDX library.

next-mdx-remoteは不要かもしれません。
React Server Componentsを使用していて、カスタムコンポーネントで基本的なMDXを使用しようとしているだけであれば、コアMDXライブラリ以外は必要ありません。

方法3:gray-matter@mdx-js/mdxを利用(🙆採用)

文字列として読み込んだmdxのコンテンツを表示するだけなら、next-mdx-remoteのドキュメントにもある通り@mdx-js/mdxを利用するとシンプルに実現できるので、このやり方を採用する。

GitHub - hashicorp/next-mdx-remote: Load MDX content from anywhere

Load MDX content from anywhere. Contribute to hashicorp/next-mdx-remote development by creating an account on GitHub.

GitHub

コンテンツを読み込むにはfsを利用するのがシンプルではあるけど、この場合mdxファイル冒頭の以下のようなfrontmatterをパースする処理も自分で実装する必要があり、それは面倒臭い。

title: 'Next.jsでブログを作る方法'
createdAt: '2025-01-01'
tags: ['Next.js']
published: true

frontmatterの取り扱いについても公式ドキュメントに記載があり、いくつかライブラリの候補が紹介されている。
今回は一番シンプルに利用できて機能的にも必要十分なgray-matterを採用した。

Guides: MDX | Next.js

Learn how to configure MDX and use it in your Next.js apps.

nextjs.org

これでコンテンツをhtmlで表示できるようにはなったけど、まだスタイルがついてないので全て通常のテキストと同じスタイルで表示されてしまっている。
スタイルの付け方もNext.jsのドキュメントで紹介されていて、今回は一番簡単に設定できて機能も十分な@tailwindcss/typographyを採用することにした。

Guides: MDX | Next.js

Learn how to configure MDX and use it in your Next.js apps.

nextjs.org

あとはmarkdownでGitHubと同じ記法を使えるようになるremark-gfmと、コードブロックにシンタックスハイライトを適用するためのrehype-pretty-codeを追加する。
ここまでの内容をまとめるとこんな感じのソースになる。

import matter from 'gray-matter'
 
import { compile, run } from '@mdx-js/mdx'
import * as runtime from 'react/jsx-runtime'
 
import rehypePrettyCode from 'rehype-pretty-code'
import remarkGfm from 'remark-gfm'
 
// 記事詳細ページ
export default async function PostPage({ params }: { params: PageParams }) {
  // ファイルの内容を読み込む
  // data:frontmatter, content:コンテンツの文字列
  const { data, content } = matter.read(filePath)
 
  // コンテンツを描画するコンポーネントを作成
  const code = String(
    await compile(content, {
      outputFormat: 'function-body',
      remarkPlugins: [[remarkGfm]],
      rehypePlugins: [[rehypePrettyCode, { theme: 'dark-plus' }]], // VSCodeのデフォルトテーマを設定
    }),
  )
  const { default: MDXContent } = await run(code, {
    ...runtime,
    baseUrl: import.meta.url,
  })
 
  // descriptionを作成
  const description = content.substring(0, 160)
 
  return (
    <div>
      {/* json-ldの設定 */}
      <Script
        id="article-structured-data"
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify({
            description,
          }),
        }}
      />
      <article>
        {/* @tailwindcss/typographyを適用するためのclassName(prose)を設定 */}
        <div className="prose">
          <MDXContent />
        </div>
      </article>
    </div>
  )
}

※別途tailwind.config.tsのpluginsでtypographyを設定する

まとめ

descriptionをfrontmatterに毎回ちゃんと書きさえすれば@next/mdxで全然良かったんだけど、毎回ちゃんと書く自信が全く無かったので少し複雑になった。
あとはそもそもjson-ld本当に必要なのかみたいな話もあるけど、まあ良い機会なので一応実装しておいた。
最終的に割と納得する形に落ち付けられて良かった。