記事一覧に戻る

Next.jsのDynamic RoutesでMDXを使う方法

はじめに

以前、『Next.jsでMDXを扱う方法』で、Next.js(App Router)でMDXを使う方法について解説しました。その中で、「Next.jsのDynamic RoutesでMDXを使うことができない」と困った点について記載していました。

だいぶ時間が経ってしまいましたが、next-mdx-remoteというパッケージを使うことで解決ができたので、今回はその方法について共有します。制約事項もあるので、その対応方法も併せて共有します。

前提

前提知識

以下の機能について、基本的な使い方は知っている前提で記載していきます。とはいえ、高度な使い方はしないため、基本の部分だけ把握していればOKです。

App Router

Next.jsのApp Routerを使うことが前提です。従来のPages Routerでは使い方は異なるのでご注意ください。

Next.js13のApp Routerを試してみたぞ!』に簡単な使い方はまとめてありますので、良かったら参考にしてください。

Dynamic Routes

Dynamic Routes(動的ルーティング)は、ページのroute(URLのディレクトリ部分)を可変にする、Next.jsの機能です。routeに対応するフォルダ名を、[slug]のように角カッコ([])で囲うやつです。

MDX

MDXはMDファイルを拡張し、ReactコンポーネントやJavaScriptのコードを埋め込めるようにしたファイル形式です。

環境

バージョン

今回の環境では、Next.jsのバージョンは14.2.3です。Node.jsのバージョンは18.18.2です。

package.json

以下の内容です。

"dependencies": {
  "@mdx-js/loader": "^3.0.1",
  "@mdx-js/react": "^3.0.1",
  "@next/mdx": "^14.2.3",
  "@types/mdx": "^2.0.13",
  "next": "14.2.3",
  "next-mdx-remote": "^4.4.1",
  "react": "^18",
  "react-dom": "^18"
}

@mdx-js/loader、@mdx-js/react、@next/md、@types/mdxは、Next.jsでMDXを利用するために必要なパッケージです。Next.js公式ドキュメントに記載されているものです。

まだ入っていない場合は、以下のコマンドでインストールの上、上記ドキュメントに沿ってセットアップしてください。もしくは、Next.jsでMDXを扱う方法を参考にしてください。

npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx

next-mdx-remoteについては後述しますが、使うことになるためインストールしておいてください。

npm install next-mdx-remote

next.config.mjs

next.jsのコンフィグは以下のとおりです。

import createMDX from "@next/mdx";

/** @type {import('next').NextConfig} */
const nextConfig = {
    // pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
};

const withMDX = createMDX({
    // md plug-in
})

export default withMDX(nextConfig);

コメント・アウトしているpageExtensionsにmdxを指定すると、mdxファイルをそのままページとして機能させることが可能(App Routerのpage.tsx|page.jsxと同じように動作させることが可能)ですが、今回は使いません。

また、MD用のプラグインもここでは使いません。

next-mdx-remoteとは

next-mdx-remoteは、「リモートにあるMDXファイルをNext.jsで利用できるようにする」パッケージです。CMSなどにMDXを格納している場合などに使われるようです。サード・パーティ製パッケージですが、Next.js公式ドキュメントにも記載がされています。

Next.jsでは、MDXを「コンポーネントとしてimport」して使うのが前提ですが、このパッケージ使うと、「MDXを文字列として取得」して使うことになります。これにより、若干コード量は増えますが、動的にMDXの内容を取得することができます。そのため、Dynamic RoutesでMDXファイルを利用することが可能になります。

なお、MDXファイルがローカルにある場合でも、next-mdx-remoteは使えます。今回の例でも、ローカルにあるファイルで試します。

問題のおさらい

もともとあった問題は、「Dynamic Routes下では、MDXを使うことができない」でした。

ここで、Next.jsでのMDXの簡単な使い方とともに、問題の原因をおさらいします。

ここはnext-mdx-remoteを使わない場合の例となりますので、ご留意ください。

MDXファイル

以下のMDXファイルをコンポーネントとしてページに表示してみます。

export const meta = {
  title: "MDXテスト",
  author: "全力君",
};

# 好きな動物

- リス
- 子豚
- マングース

page.tsx

// ContentはMD部分をコンポーネントとしてパースされたオブジェクト
// { meta }はMDX内でexportしているオブジェクト
import Content, { meta } from "./page.mdx";

export default function page() {
  return (
    <>
      <header>
        <h1>{meta.title}</h1>
        <div>{meta.author}</div>
      </header>
      <article>
        {/* MDXをコンポーネントとして使うのがポイント */}
        <Content />
      </article>
    </>
  );
}

上記のMDXファイルをimportし、コンポーネントとして利用しているのがポイントです。加えて、MDX内でexportしたデータ(meta変数)も、同じようにimportして利用することが可能です。

ブラウザで表示

こんな感じでちゃんと表示されます。

simple-mdx-example

問題点

上記のとおり、「MDXをimportするとReactのコンポーネントとして扱える」、というのがNext.jsでのMDXの使い方となります。

Dynamic Routesで利用しようとなると、routeに応じて異なるMDXファイルのパスを指定し、importする必要があります。しかし、MDXファイルを動的にimportしようとしても、「モジュールが見つからない(unhandledRejection: Error: Cannot find module パス名)」とエラーになってしまいます。コード等の詳細はNext.jsでMDXを扱う方法#少しこまったことに記載してあるので参考にしてください。

なお、import関数の代わりにrequireを使っても、Next.jsのdynamic関数を使っても同じ結果になります。

MDXをコンポーネントとしてimportできるのは、next.configの設定や、@next/mdx等のインストールしたパッケージののおかげですが、動的なimportまではサポートしていないようです。

申し訳ないのですが、原因は現時点でもはっきりは分かってはいません。単純にパッケージ側が動的importをサポートしていないだけなのか、TypeScriptやバンドラのコンフィグの問題なのか、それともServer Components/Client Componentsの使い方なのか、切り分けが出来ていません。

とにかく、「動的にMDXをimportすることができず、Dynamic Routes下では利用ができない」ということです。

Dynamic RoutesでMDXを使う

それでは、next-mdx-remoteを使ってDynamic Routesのページを生成してみます。

Dynamic RoutesはNext.jsのいつも通りの使い方です。今回は、/app/test/[dir]というrouteにします。

MDXファイルについて

MDXファイルは/mdxフォルダに配置します。このフォルダには/app/test/[dir]の「dir」と同じ名前のフォルダを作成しておき、ここにMDXファイルを「page.mdx」の名前で保存しておきます。

例えば、「dir」が「p1」の場合、対応するMDXファイルは「/mdx/p1/page.mdx」に保存されています。

なお、内容は以下のとおり2ファイル作成済みです。

/mdx/p1/page.mdx
# ページ1のテスト

## 好きなくだもの

- バナナ
- オレンジ
- リンゴ
/mdx/p2/page.mdx
# ページ2のテスト

## 好きな動物

- いたち
- テン
- ハクビシン

routeの生成

ここから、/app/test/[dir]直下のpage.tsxを編集します。

まずはgenerateStaticParamsでrouteを生成する関数を作成します。「p1」、「p2」を生成しています。

app/test/[dir]/page.tsx
export async function generateStaticParams(){
  const posts = ["p1", "p2"];
    return posts.map((dir) => {
        return { dir };
  })
}

MDXを読み取る関数

MDXを読み取る関数を追加します。ページ(Server Component)から呼び出すので、非同期関数にする必要があります。名前はなんでも良いです。今回は、loadMDXにしています。

app/test/[dir]/page.tsx
import path from "node:path";
import { readFile } from "node:fs/promises";
import { compileMDX } from "next-mdx-remote/rsc";

export async function generateStaticParams(){
//  上と同じのため省略
}

/**
 * @param dir urlのroute(p1,p2,p3)
 */
async function loadMDX(dir: string) {
  // projectのルート・パス
  const root = path.resolve();
  // mdxファイルのパス
  const mdxpath = path.join(root, "mdx", dir, "page.mdx");
  // mdxファイルを読み取る
  const data = await readFile(mdxpath, { encoding: "utf-8" });
  // mdxをパースする。
  // remark,rehypeのプラグインを指定する場合、
  // front-matterもパースする場合、ここで指定する
  return compileMDX({
    source: data,
  })
}

pathreadFileはMDXファイルを開くためにimportしています。

compileMDXがnext-mdx-remoteの関数で、MDXをパースしてくれます。sourceパラメタに、MDXを文字列として受け取ります。他にも、ここでオプションの指定をすることで、rehypeやremarkのプラグイン指定したり、いわゆるfront-matterをパースすることができます。

この例では、Dynamic Routes下でMDXを使えるかの検証がメインなので、sourceのみ使います。他のオプションは後述します。

ポイントは、「MDXをファイルとして開き、compileMDXでパース」している部分です。next-mdx-remoteを使わない場合は、MDXをモジュールとしてimportしていたので、ここは大きな違いです。これにより、動的にMDXを選択することが可能になります。

なお、compileMDXの戻り値は、contentfrontmatterの2つのプロパティをもったオブジェクトとなります。前者は、MDXのパース結果、後者は、front-matter部分のパース結果となります。

ページを追加

ページ部分を追加します。

app/test/[dir]/page.tsx
// import 部分は同じのため省略

export async function generateStaticParams(){/*上記のとおり*/}

async function loadMDX(dir: string) {/*上記のとおり*/}

export default async function Page(
  { params }: { params: { dir: string } }
) {
  /**
   * {
   *  content: MDXのパース結果, 
   *  frontmatter: front-matterのパース結果 
   * }
   */
  const mdx = await loadMDX(params.dir)
  return (
    <>
      {mdx.content}
    </>
  )
}

dynamic routeに応じて、さきほど作成したloadMDX関数を呼び出し、MDXファイルのパース結果を受け取っています。上述のとおり、contentがMDX部分のパース結果になるので、それを直接レンダリングしています。

ブラウザで確認

npm run devで起動させ、/test/p1/test/p2が表示されるか確認します。

  • p1ページ
p1-page-width-dynamic-routes
  • p2ページ
p2-page-width-dynamic-routes

ちゃんとdynami routesでもMDXの利用ができました!やったぜ!

next-mdx-remoteの制約

無事Dynamic RoutesでMDXを使えましたが、これで万事OKではありません。next-mdx-remoteにはいくつか制約事項がありますので、それを紹介します。解決方法は次の章で触れます。

MDX内でimportとexportができない

next-mdx-remoteでは、MDXファイル内にimport文とexport文を使うことができません。使えない理由は、GitHubに以下のように記載されていました。

~前略~, imports must be relative to a file path, and this library allows content to be loaded from anywhere, rather than only loading local content from a set file path. As for exports, the MDX content is treated as data, not a module, so there is no way for us to access any value which may be exported from the MDX passed to next-mdx-remote.

「importはファイル・パスに対して相対的である必要がありますが、このライブラリは、コンテンツを固定されたファイル・パスからだけでなく、どこからでもロードできるようにします。exportについては、MDXをモジュールでなくデータとして扱うため、next-mdx-remoteに渡されたMDXからexportされる値に、アクセスする術はありません。」とのことです。

import文

MDXにReactのコンポーネントを直接importできるのは、MDXの大きな利点の1つです。Next.jsのImageコンポーネントのような強力なコンポーネントをそのまま使えるので、とても便利だと思っています。正直、使えないと困ります。

export文

従来のMDファイルではfront-matterを使って定義していたデータを、MDXではexport文を使って定義することができました。

  • MDでfront-matterを使う場合
---
title: ページ1
author: 全力君
description: フロント・マッターの例
---

# ページ1

あいうえお~~
  • MDXでexportを使う場合
export const meta = {
  title: "MDXテスト",
  author: "全力君",
  description: "exportの例",
};

# ページ1

あいうえお~~

export文はNext.jsのドキュメントにも記載されている使い方なので、使っている方も多いと思います。

残念ながら、next-mdx-remoteではこのような使い方はできません。

next.configで指定したプラグインが効かない

MDでもMDXでも、HTMLへの変換にはremarkrehypeが使われています。

remarkやrehypeのプラグインを使う場合、本来であれば以下のようにnext.configファイルに指定をします。例として、GitHub版のマークダウン表記に対応させるremarkGfmと、プログラムの構文ハイライトを補助してくれるrehypePrismの指定をしています。

next.config.mjs
import remarkGfm from 'remark-gfm';
import rehypePrism from 'rehype-prism';
import createMDX from "@next/mdx";

const nextConfig = {
  // 略
};

const withMDX = createMDX({
  options: {
    extension: /\.mdx?$/,
    // remark系のpluginを指定
    remarkPlugins: [remarkGfm],
    // rehype系のpluginを指定
    rehypePlugins: [rehypePrism],
  },
})

export default withMDX(nextConfig);

しかし、next-mdx-remoteを使う場合、next.configで指定したプラグインは効きません。これは、MDXをモジュールとして扱わず(MDXを直接importしない)、ファイルとして開いて手動でパースするからです。そのため、next.configの設定が効きません。

制約事項の解決方法

上記で説明した、next-mdx-remoteを使う場合の制約事項について、それぞれ解決方法を紹介します。

import問題を解決

MDX内でコンポーネントを直接importをすることはできませんが、MDX内で使うコンポーネントを、compileMDXのオプションで指定することで解決できます。

例として、Next.jsのImageコンポーネントと、自作のHighlightコンポーネント(背景色を黄色にするだけのコンポーネント)をMDXで使ってみます。

MDX

MDX内ではコンポーネントのimportができないので、importせずにそのままJSXを記述するのがポイントです。

# Reactのコンポーネントを使う

MDXにはimport文は書きません。

<Highlight>
やっほ~
</Highlight>

これはNext.jsのImageコンポーネントです。

<Image src="/mdx/p2/test.png" alt="test" width={317} height={277} />

importがないとちょっと気持ちが悪いかもしれませんが、しょうがありません。

ちなみに、importとexportがある場合は無視してパースされます。そのため書いても問題ありませんが、後から見直した時に混乱しそうなので、私は書かないことにします。

page.tsx

上記のMDXを表示するページを以下のように作成します。

page.tsx
import path from "node:path";
import { readFile } from "node:fs/promises";
import Image from "next/image";
import HighLight from "./HighLight";
import { compileMDX } from "next-mdx-remote/rsc";

async function loadMDX() {
  // mdxファイルのパスを取得
  const root = path.resolve();
  const mdxfile = path.join(root, "app/test2", "page.mdx")
  // mdxファイルを読み取る
  const data = await readFile(mdxfile, { encoding: "utf-8" });

  // パースする。MDXで使用するコンポーネントは"components"に指定する。
  return compileMDX({
    source: data,
    components: {
      Image: (props: any) => <Image {...props} />,
      HighLight: (props: any) => <HighLight {...props} />,
    },
  });
}

export default async function Page() {
  const mdx = await loadMDX();
  return (
    <>
      {mdx.content}
    </>
  );
}

ポイントは、compileMDXで指定している、componentsプロパティです。ここに、MDX内で利用するReactコンポーネントを指定します。厳密には、「利用するコンポーネントを返す関数」の形で指定しています。

ここで、1つ注意点があります。

本来であれば、MDXで利用するコンポーネントは、以下のように直接指定することが可能です。

import Image from "next/image";
import HighLight from "./HighLight";

async function loadMDX() {
  /**略*/
  return compileMDX({
    source: data,
    components: {
      Image, HighLight
    },
  });
}

しかし、Next.jsのImageコンポーネントだと、以下のエラーが出てしまいます。

Internal error: Error: Cannot access Image.propTypes on the server. You cannot dot into a client module from a server component. You can only pass the imported name through.

「Server Componentsからclientモジュールを使うことができない」といったエラーが表示されています。この件は、Github Issueでも議論されています。

エラーメッセージは正確ではなく、他のコンポーネントでも発生する場合もあるようです。バージョンによる動作の違いもありそうですね。

関数でラップする回避策は、ここに記載があったものです。

基本的には関数でラップする必要はありませんが、もし上記のようなエラーが発生した場合は、試してみてください。上記のIssueに他の回避策も記載されているので、それでも解決しない場合は参考になると思います。

ブラウザでチェック

mdx-with-components

ちゃんと、ImageコンポーネントとHighLightコンポーネントが効いてることが確認できます。

export問題を解決

MDXでexportできない問題ですが、これはMDXにfront-matterをつけて対応します。next-mdx-remoteにfront-matterをパースする機能がついているため、追加のパッケージのインストールは不要です。

MDXに、ページのタイトル(見出し)と作者情報をfront-matterで設定し、ページに適用してみます。

MDX

MDXのfront-matterを追加しています。上部の「---(ハイフン3つ)」で区切られている部分がそれです。

---
title: front-matter
author: 全力君
---

# front-matterのテスト!

あいうえお~~

page.tsx

import path from "node:path";
import { readFile } from "node:fs/promises";
import { compileMDX } from "next-mdx-remote/rsc";

async function loadMDX() {
  const root = path.resolve();
  const mdxpath = path.join(root, "app/front-matter", "page.mdx");
  const data = await readFile(mdxpath, { encoding: "utf-8" });
  return compileMDX({
    source: data,
    // options:{parseFrontmatter:true}を設定することで、
    // MDX内のfront-matterをパースしてくれる。
    options: {
      parseFrontmatter: true,
    }
  })
}

export default async function Page() {
  const mdx = await loadMDX();

  // content -> MDX部分
  const content = mdx.content;
  // frontmatter -> front-matter部分
  const frontmatter = mdx.frontmatter as { title: string, author: string };

  return (
    <div>
      <header style={{/*略*/}}>
        <h1>{frontmatter.title}</h1>
        <div>{frontmatter.author}</div>
        <div>{new Date().toLocaleString()}</div>
      </header>
      <article>
        {content}
      </article>
    </div>
  )
}

ポイントは、またもやcompileMDX関数です。今回は、パラメタに{options: parseFrontmatter:true}を追加しています。このオプションを入れると、front-matter部分をパースしてくれます。

compileMDXの戻り値のfrontmatterプロパティに、パース結果が格納されています。後は普通に使うだけです。今回は、titleauthorをfront-matterに指定しているので、headerタグ内で使っています。

ブラウザで確認

mdx-with-frontmatter

ちゃんとMDXのfont-matter部分が見出しとして表示されています。赤枠部分です。

next.config問題を解決

今度は、remarkやrehypeのプラグインを指定する方法です。next.configで指定しても効かないので、またしてもcompileMDXのオプションで指定します。

今回は例として、rehypeのプラグインであるrehype-prismを使います。プログラミング言語の構文に沿って、ハイライトしてくれるプラグインです。

MDX

pythonのコードを追加しています。

# rehypeプラグインのテスト

```python
import os

def test():
    print(os.environ)
```

page.tsx

ページを以下のように作成します。

なお、importしているjsファイルは、pythonの構文をパースするためのスクリプトで、cssファイルは、シンタックス・ハイライトをするためのものです。いずれも、rehype-prisimに付属するファイルです。

import path from "node:path";
import { readFile } from "node:fs/promises";
import { compileMDX } from "next-mdx-remote/rsc";
import rehypePrism from "rehype-prism";
// rehype-prismでpythonのシンタックス・ハイライトをするために必要
import "prismjs/components/prism-python.js";
// シンタックス・ハイライト用のCSS
import "prismjs/themes/prism-tomorrow.css";

async function loadMDX() {
  const root = path.resolve();
  const mdxpath = path.join(root, "app/conf", "page.mdx");
  const data = await readFile(mdxpath, { encoding: "utf-8" });
  return compileMDX({
    source: data,
    options: {
      // mdxOptionsでremarkやrehypeのプラグインを指定する。
      // 複数ある場合はカンマ区切り。
      mdxOptions: {
        // @ts-ignore
        rehypePlugins: [rehypePrism],
        remarkPlugins: [],
      }
    }
  });
}

export default async function Page() {
  const mdx = await loadMDX();
  return (
    <>{mdx.content}</>
  )
}

まず、使いたいプラグインをimportします。今回の例だとimport rehypePrism from "rehype-prism"の部分です。

後は、compileMDXのパラメタのoptions.mdxoOptionsに指定します。以下の部分ですね。

return compileMDX({
  source: data,
  options: {
    mdxOptions: {
      // @ts-ignore
      rehypePlugins: [rehypePrism],
      remarkPlugins: [],
    }
  }
});

rehypePluginsremarkPluginsとあるとおり、remark系・rehype系のプラグインでそれぞれ指定するオプションが異なります。いずれも配列で指定し、複数ある場合はカンマ区切りで指定すればOKです。

なお、@ts-ignoreでTypeScriptのエラーを無視しています。これを外すと、私の環境だと以下の型エラーが出ます。

Type 'Plugin<[(RehypePrismOptions | undefined)?], Element>' is not assignable to type 'Pluggable<any[]>'.Type 'Plugin<[(RehypePrismOptions | undefined)?], Element>' is not assignable to type 'Plugin<any[], any, any>'. The 'this' types of each signature are incompatible. Type 'Processor<void, any, void, void> | Processor<void, any, any, any> | Processor<any, any, void, void> | Processor<void, void, void, void>' is not assignable to type 'Processor<undefined, undefined, undefined, undefined, undefined>'. Type 'Processor<void, any, void, void>' is missing the following properties from type 'Processor<undefined, undefined, undefined, undefined, undefined>': compiler, freezeIndex, frozen, namespace, and 3 more.

next-remote-mdxとrehype-prismそれぞれで定義されている型の違いのものなので、解決には労力を割かず無視しています。動作には影響ありません。

ブラウザで確認

ちゃんとpythonの構文部分がハイライトされていることが確認できますね。

最後に

今回は、next-mdx-remoteを使って、Next.jsのDynamic RoutesでMDXファイルを使う方法を共有しました。併せて、next-mdx-remoteの制約事項と、その対応方法についても解説をしました。

このサイトでも、Dynamic Routesを使わずにMDXを使っているページがいくつかありますが(このページもそうです)、ようやく解決の糸口が見えました。しかし、今数えてみると15ページくらいあるので、import文やexport文を修正するだけでも結構労力がかかりそうです。しかし、Dynamic Routesを使えばISRで全体をビルドせずにページの追加が可能になるので、前向きに移行の検討をしたいと思います。

なお、使っていて気になったのですが、Next.jsの公式サイトで紹介されていた@next/mdxとかのパッケージは、インストールする必要はないのかもしれません。時間がある時に、必要最低限のインストールとコンフィグ変更について、調べてみようと思います。何かあれば、このページに追記をしていきます。

しかし、Next.jsでServer Componentsが導入され、この1年間で数々のアップデートが入ったように思います。それに合わせて、サード・パーティ製のパッケージも対応が必要になるので、エラーの解決だけでなく、再現もなかなか難しいですね。ついていくのが大変です!

2024-05-27追記:最小限のパッケージとコンフィグ

next-mdx-remoteを使うために、必要最低限となるパッケージとコンフィグについて調べたので、追記をします。

結論、Next.js公式ドキュメントの手順は不要です。

追加するパッケージはnext-mdx-remoteだけでOKですし、next.configも手を加える必要はありません。mdx-components.tsxも不要です。その代わり、MDXファイルをモジュールとしてimportすることはできなくなります。この使い方もしたい場合は、手順に沿って設定する必要があります。

ただし、私が試した範囲だと、remarkやrehypeのプラグインを指定した時に、以下のエラーが発生する場合がありました。

⨯ [Error: [next-mdx-remote] error compiling MDX: Cannot parse without Parser

ちなみに、追加したプラグインはrehype-prismです。原因の詳細は分かっていませんが、@mdx-js/mdxのバージョンが影響をしているようです。npm i @mdx-js/mdx@latestでインストールしなおしたら解決しました。

もしプラグインを指定した時にエラーが出る場合は、試してみてください。

参考

記事一覧に戻る