Next.js App Router向けのMDXコンテンツマネジメントツールを自作する

## 概要

Next.js App Routerで使用する簡易なMDXコンテンツマネジメントツールを自作する記事です。RSC (React Server Components)で使うことを前提としています。

## 使用例

まず先に使用例を提示します。

### プロジェクト構造

```
.
├── content
│   ├── author // 著者情報のYAMLファイルの置き場所
│   └── post // Markdown/MDXファイルの置き場所
├── public
│   └── assets
├── src
│   ├── app
│   ├── components
│   ├── content
│   └── mdx-components.tsx
├── next.config.mjs
├── package.json
└── tsconfig.json
```

`src/content/index.ts`内でMarkdown/MDXコンテンツを扱うコンテンツオブジェクト`post`を`defineMdx`関数で、著者情報を扱うコンテンツオブジェクト`author`を`defineData`関数で定義し、エクスポートします。

```tsx:src/content/index.ts
import { z } from "zod";

export const post = defineMdx({
  contentPath: path.resolve(process.cwd(), "content/post"),
  basePath: "/post",
  schema: {
    author: z.string().optional(),
  },
});
export type PostFrontmatter = z.infer<typeof post.schema>;
export type PostMetadata = z.infer<typeof post.metadataSchema>;

export const author = defineData({
  contentPath: path.resolve(process.cwd(), "content/author"),
  schema: {
    name: z.string(),
    description: z.string().optional(),
  },
});
export type Author = z.infer<typeof author.schema>;
```

:::note warn
2024/03/25 改訂
初稿では`defineMdx`, `defineData`のオプション`schema`は`z.object()`でラップした`ZodObject`にしていましたが、ラップを外し`ZodRawShape`の型と変更しました。
:::

コンテンツの定義が完了したら`src/app/post/[…slug].tsx`内で先に定義した`post`と`author`をインポートして使用します。

```tsx:src/app/post/[...slug].tsx
import NextLink from "next";
import { post, author } from "@/content";

export async function generateStaticParams() {
  const allPosts = await post.getAll();
  return allPosts;
}

export default async function Page({ params }: { params: { slug: string[] } }) {
  const { slug } = params;
  const item = await post.useMdx(slug);
  if (!item) return null;
  const { content, frontmatter } = item;
  const { title, date } = frontmatter;
  const authorData = await author.get("name", frontmatter.author);

  return (
    <article>
      <header>
        <h1>{title}</h1>
        <time>{date}</time>
      </header>
      {content}
      <footer>
        <p>{title}</p>
        <time>{date}</time>
        {authorData && (
          <address>
            <p>{authorData.name}</p>
            {authorData.description && <small>{authorData.description}</small>}
          </address>
        )}
      </footer>
    </article>
  );
}
```

**この記事の主旨は上記のような使い方ができるコンテンツマネジメントツールの`defineMdx`関数と`defineData`関数を作成することです**。

## 設計

Astroの[コンテンツコレクション](https://docs.astro.build/ja/guides/content-collections/)に着想を得て以下のような設計にしています。

- コンテンツやデータを`src`以外のディレクトリに置く
- コンテンツやデータを`src/content/index.ts`で定義しエクスポートする
- コンテンツ定義ではMarkdown/MDXのフロントマターやデータの型をZodスキーマで定義する
- コンテンツオブジェクトは全てのコンテンツのメタデータを取得する`await getAll()`と特定のコンテンツのメタデータを取得する`await get()`をメソッドに持つ
- `await getAll()`と`await get()`の返り値はZodで定義した型を基に型安全にする
- Markdown/MDXのコンテンツでは隣接する記事の情報を`context`として扱う

## サンプルプロジェクト

サンプルプロジェクト
https://nextjs-mdx-content-management-example.vercel.app/

サンプルプロジェクトのリポジトリ
https://github.com/cieloazul310/nextjs-mdx-content-management-example

## 環境

### 使用ライブラリ

- [next-mdx-remote] v4.4.1
- [Zod] v3.22.4
- yaml v2.4.1

## 作成例

それでは`defineMdx`関数と`defineData`関数の作成例を見ていきます。

### MDXコンテンツを定義する`defineMdx`関数

指定したディレクトリ内のMarkdown/MDXファイルを読み込みコンテンツとして定義する`defineMdx`関数の作成例です。

`defineMdx`関数で定義したコンテンツオブジェクトは以下のようなメソッド・プロパティを持ちます。

- `await getAll()` : すべてのMarkdown/MDXコンテンツのメタデータを返す
- `await get()` : 特定のMarkdown/MDXコンテンツのメタデータを返す
- `await useMdx()` : 特定のMarkdown/MDXコンテンツを変換したReactコンポーネントとフロントマターを返す
- `schema` : Markdown/MDXコンテンツのフロントマターのZodスキーマを返す
- `metadataSchema` : Markdown/MDXコンテンツのメタデータのZodスキーマを返す

やや冗長な部分があったり、型パズルが解けずに型アサーション(`as`)を使っている部分があるのは不本意ですが、あくまで作成例の一つとしてご覧ください。

```tsx:src/content/defineMdx.ts
import * as path from "path";
import { readdir, readFile } from "fs/promises";
import { compileMDX, type MDXRemoteProps } from "next-mdx-remote/rsc";
import { z, type ZodObject, type ZodRawShape } from "zod";
import { fileNameToSlug, dataSchemaVaridator } from "./utils";

const defaultFrontmatterSchema = z.object({
  title: z.string(),
  date: z.coerce.date(),
  lastmod: z.coerce.date(),
  draft: z.boolean(),
});
const defaultFrontmatterSchemaInput = defaultFrontmatterSchema.partial({
  lastmod: true,
  draft: true,
});

export type Frontmatter<
  T extends Record<string, any> = Record<string, unknown>,
> = T & z.infer<typeof defaultFrontmatterSchema>;

export type FrontmatterInput<
  T extends Record<string, any> = Record<string, unknown>,
> = T & z.infer<typeof defaultFrontmatterSchemaInput>;

export type Metadata<
  TFrontmatter extends Record<string, any> = Record<string, unknown>,
> = {
  frontmatter: TFrontmatter;
  absolutePath: string;
  slug: string[];
  href: string;
};

function complementFrontmatter<T extends Record<string, any>>({
  title,
  date,
  lastmod,
  draft,
  ...rest
}: FrontmatterInput<T>): Frontmatter<T> {
  return {
    title,
    date: new Date(date),
    lastmod: lastmod ? new Date(lastmod) : new Date(date),
    draft: typeof draft === "boolean" ? draft : false,
    ...rest,
  } as Frontmatter<T>;
}

export function defineMdx<Z extends ZodRawShape>({
  contentPath,
  basePath,
  schema,
  extensions = ["md", "mdx"],
}: {
  contentPath: string;
  basePath: string;
  schema: Z;
  extensions?: string[];
}) {
  const restFrontmatter = z.object(schema);
  type RestFrontmatter = z.infer<typeof restFrontmatter>;
  const frontmatterSchema = defaultFrontmatterSchema
    .merge(restFrontmatter)
    .passthrough();
  const metadataSchema = z.object({
    frontmatter: frontmatterSchema,
    absolutePath: z.string(),
    slug: z.array(z.string()),
    href: z.string(),
  });
  const varidator = dataSchemaVaridator(frontmatterSchema);

  async function getAll(): Promise<
    (Metadata<Frontmatter<RestFrontmatter>> & {
      context: {
        older: Metadata<Frontmatter<RestFrontmatter>> | null;
        newer: Metadata<Frontmatter<RestFrontmatter>> | null;
      };
    })[]
  > {
    const filesInDir = await readdir(contentPath, {
      encoding: "utf8",
      recursive: true,
    });
    const files = filesInDir.filter((fileName) =>
      extensions.some((ext) => new RegExp(`.${ext}$`).test(fileName)),
    );

    const allPosts: Metadata<Frontmatter<RestFrontmatter>>[] = (
      await Promise.all(
        files.map(async (filename) => {
          const absolutePath = path.join(contentPath, filename);
          const source = await readFile(absolutePath, { encoding: "utf8" });
          const { frontmatter } = await compileMDX<
            FrontmatterInput<RestFrontmatter>
          >({
            source,
            options: { parseFrontmatter: true },
          });
          return {
            data: complementFrontmatter(frontmatter),
            absolutePath,
            filename,
          };
        }),
      )
    )
      .filter(varidator)
      .map(({ data, absolutePath, filename }) => {
        const slug = fileNameToSlug(filename);
        const href = path.join(basePath, ...slug);

        return {
          frontmatter: data,
          absolutePath,
          slug,
          href,
        };
      });

    return allPosts
      .filter(
        ({ frontmatter }) =>
          process.env.NODE_ENV === "development" || !frontmatter.draft,
      )
      .sort(
        (a, b) =>
          a.frontmatter.date.getTime() - b.frontmatter.date.getTime() ||
          a.frontmatter.lastmod.getTime() - b.frontmatter.lastmod.getTime(),
      )
      .map((post, index, arr) => ({
        ...post,
        context: {
          older: arr[index - 1] ?? null,
          newer: arr[index + 1] ?? null,
        },
      }));
  }

  async function get(slug: string[]) {
    const alls = await getAll();
    const datum = alls.find((post) => post.slug.join("/") === slug.join("/"));
    return datum;
  }

  async function useMdx(
    slug: string[],
    { components, options }: Omit<MDXRemoteProps, "source"> = {},
  ) {
    const datum = await get(slug);
    if (!datum) return null;
    const { absolutePath, context, frontmatter } = datum;
    const file = await readFile(absolutePath, { encoding: "utf8" });
    const { content } = await compileMDX({
      source: file,
      components,
      options: {
        ...options,
        parseFrontmatter: true,
      },
    });
    return {
      content,
      context,
      frontmatter,
    };
  }

  return {
    schema: frontmatterSchema,
    metadataSchema,
    get,
    getAll,
    useMdx,
  };
}
```

:::note warn
2024/03/25 改訂
初稿では`Metadata`におけるフロントマターは、トップレベルでマージしていましたが、改訂後はプロパティ`frontmatter`内に格納しています。
:::

### データを定義する`defineData`関数

指定したディレクトリ内のYAMLファイルやJSONファイルを読み込みデータとして定義する`defineData`関数の作成例です。

`defineData`関数で定義したコンテンツオブジェクトは以下のようなメソッド・プロパティを持ちます。

- `await getAll()` : すべてのデータ(メタデータ込み)を返す
- `await get()` : 特定のデータ(メタデータ込み)を返す
- `schema` : データのZodスキーマを返す

```tsx:src/content/defineData.ts
import { readFile, readdir } from "fs/promises";
import * as path from "path";
import { z, type ZodRawShape } from "zod";
import { dataSchemaVaridator, dataFormatter, type DataFormat } from "./utils";

export function defineData<T extends ZodRawShape>({
  contentPath,
  schema,
  format = "yaml",
}: {
  contentPath: string;
  schema: T;
  format?: DataFormat;
}) {
  const { extensions, parser } = dataFormatter(format);
  const dataSchema = z.object({ id: z.string() }).extend(schema).passthrough();
  const varidator = dataSchemaVaridator(dataSchema);

  async function getAll(): Promise<z.infer<typeof dataSchema>[]> {
    const filesInDir = await readdir(contentPath, {
      encoding: "utf8",
      recursive: true,
    });
    const files = filesInDir.filter((fileName) =>
      extensions.some((ext) => new RegExp(`.${ext}$`).test(fileName)),
    );
    const collection = (
      await Promise.all(
        files.map(async (filename) => {
          const absolutePath = path.join(contentPath, filename);
          const file = await readFile(absolutePath, "utf8");
          const datum = parser(file);
          return {
            data: { id: filename.replace(/\.[^/.]+$/, ""), ...datum },
            filename,
          };
        }),
      )
    )
      .filter(varidator)
      .map(({ data }) => data);

    return collection;
  }

  async function get(
    key: keyof z.infer<typeof dataSchema>,
    value: unknown,
  ): Promise<z.infer<typeof dataSchema> | undefined> {
    const data = await getAll();
    return data.find((datum) => datum?.[key] === value);
  }

  return {
    schema: dataSchema,
    get,
    getAll,
  };
}
```

### ユーティリティ

```tsx:src/content/utils.ts
import { z, type ZodType } from "zod";
import * as yaml from "yaml";

/**
 * example:
 * getting-started.mdx => ["getting-started"]
 * 2024/hoge.mdx => ["2024", "hoge"]
 * 2024/nested-post/index.md => ["2024", "nested-post"]
 */
export function fileNameToSlug(filename: string) {
  const indexPattern = /\/index.(md|mdx)$/;
  const pattern = /.(md|mdx)$/;
  return filename.replace(indexPattern, ".mdx").replace(pattern, "").split("/");
}

export function schemaVaridator<T extends ZodType>(schema: T) {
  return (data: unknown): data is z.infer<typeof schema> => {
    const result = schema.safeParse(data);
    if (!result.success) {
      console.error(result.error.message);
    }
    return result.success;
  };
}

export function dataSchemaVaridator<T extends ZodType>(schema: T) {
  return (input: {
    data: unknown;
    filename: string;
  }): input is { data: z.infer<typeof schema>; filename: string } => {
    const { data, filename } = input;
    const result = schema.safeParse(data);
    if (!result.success) {
      console.error(filename, result.error.message);
    }
    return result.success;
  };
}

export const dataFormat = z.enum(["yaml", "json"]);
export type DataFormat = z.infer<typeof dataFormat>;

export function dataformatToExts(format: DataFormat) {
  if (format === "yaml") return ["yml", "yaml"];
  return ["json"];
}

export function parseData(format: DataFormat) {
  if (format === "yaml") return (raw: string) => yaml.parse(raw);
  return (raw: string) => JSON.parse(raw);
}

export function dataFormatter(format: DataFormat) {
  const extensions = dataformatToExts(format);
  const parser = parseData(format);
  return { extensions, parser };
}
```

## Tips

### Markdown/MDXコンテンツを検索し、ファイルパスからSlugを作成する

Next.js App Routerのダイナミックルーティングは`generateStaticParams`関数をエクスポートすることでパラメータからルートを作成することができます。このときファイル名を`[…slug].tsx`とすることで、スラッシュで区切られた階層型のルートを作成することができます。

```tsx
// src/app/post/[...slug].tsx
import { readdir, readFile } from "fs/promises";
import * as path from "path";

async function getAllMdxFiles(contentPath: string) {
  const filesInDir = await readdir(contentPath, {
    encoding: "utf8",
    recursive: true,
  });
  const files = filesInDir.filter((fileName) =>
    [".md", ".mdx"].some((ext) => new RegExp(`${ext}$`).test(fileName)),
  );
  return files.map((fileName) => ({
    slug: fileNameToSlug(fileName), // => string[]
  }));
}

export async function generateStaticParams() {
  const contentPath = path.resolve(process.cwd(), "content/post");
  const allMdxFiles = await getAllMdxFiles(contentPath);
  return allMdxFiles;
}

export default async function Page({
  params,
}: {
  params: { slug: string[] },
}) {
  const { slug } = params;
  // ...
}
```

上の例では`generateStaticParams`関数でエクスポートする`slug`の型は`string[]`となるため、ファイルパスをSlugに変換する作業が必要になります。ここでは`/hoge/index.mdx`のパターンを`hoge.mdx`と同様の扱いにするため、ここでは`String.replace`を2回実行する力技を用いています。

```tsx
/**
 * example:
 * getting-started.mdx => ["getting-started"]
 * 2024/hoge.mdx => ["2024", "hoge"]
 * 2024/nested-post/index.md => ["2024", "nested-post"]
 */
function fileNameToSlug(filename: string) {
  const indexPattern = /\/index.(md|mdx)$/;
  const pattern = /.(md|mdx)$/;
  return filename.replace(indexPattern, ".mdx").replace(pattern, "").split("/");
}
```

### SlugからMarkdown/MDXファイルを取得する

Next.js App Routerのダイナミックルーティングの場合、デフォルトエクスポートを行うページの関数では**ルーティングに用いた変数のみ**がパラメータとしてPropsに渡されます。上の例では`slug`がパラメータとして渡されるため、ページの関数内では`slug`からMarkdown/MDXファイルを取得するフックが必要となります。

```tsx
export default async function Page({ params }: { params: { slug: string[] } }) {
  const { slug } = params;
  const item = await useMdx(slug);
  if (!item) return null;

  const { content, frontmatter, context } = item;
  const { title, date } = frontmatter;
  const { older, newer } = context;

  // ...
}
```

### 開発時には下書き記事を表示、本番環境では非表示

```tsx
async function getAll() {
  return allPosts
    .filter(({ draft }) => process.env.NODE_ENV === "development" || !draft);
}
```

### Zodでスキーマを定義する

Zodはスキーマの定義、検証のためのライブラリです。Zodでは以下の例のようにスキーマを定義することができます。定義したスキーマは`z.infer`でTypeScriptの型定義に変換することができます。

```tsx
import { z } from "zod";

const schema = z.object({
  id: z.string(),
  name: z.string(),
  age: z.number().optional(),
});

type Schema = z.infer<typeof schema>;
// => { id: string; name: string; age?: number | undefined }

const extended = schema.extend({
  socials?: z.object({
    twitter: z.string().optional(),
    github: z.string().optional(),
  }).optional(),
});

type ExtendedSchema = z.infer<typeof extended>;
// => { id: string; name: string; age?: number; socials?: { twitter?: string | undefined; github?: string | undefined; } | undefined }
```

`defineMdx`関数や`defineData`関数ではユーザがZodでスキーマを定義します。これにより、`await getAll()`、`await get()`メソッドはユーザが定義したZodスキーマを基に、型安全なデータを返します。

Zodスキーマは拡張・変形が可能です。`defineMdx`関数ではフロントマターに必須となるフィールド(`title`, `date`)とインプットでは必須ではないもののアウトプットでは必ず存在するフィールド(`lastmod`, `draft`)をユーザーが定義した型と結合しています。

`defineData`関数では`id`フィールドをファイル名から生成し、ユーザーが定義する型と結合しています。

### Zodでデータの型チェックを行う

Zodの重要な機能にデータの検証があります。Zodスキーマは`parse`と`safeParse`の2種類のパーサをメソッドに持ちます。`parse`はデータの型チェックが成功した場合はそのままデータを返し、失敗した場合はエラーを投げます。一方`safeParse`は型チェックの失敗成功に関わらず返り値がオブジェクトであるため、例外処理を扱いやすいメリットがあります。

この記事では単純な型チェックだけを扱いますが、Zodのスキーマ検証には文字列の解析や数値の最大値、最小値の設定など多くの機能が備わっています。

```tsx
import { z } from "zod";

const schema = z.object({
  id: z.string(),
  name: z.string(),
  age: z.number().optional(),
});

const foo = { id: "foo", name: "Foo" };
const bar = { id: "bar", name: 20, age: 20 };

schema.parse(foo); // => { id: "foo", name: "Foo" }
schema.parse(bar); // => throws error

schema.safeParse(foo);
// => { success: true, data: { id: "foo", name: "Foo" } }
schema.safeParse(bar);
// => { success: false; error: ZodError }
```

以下のコードはZodスキーマの`safeParse`を使って`any`型の配列データを型安全な配列に変換・フィルタリングするvaridator関数を作成する例です。

```tsx
import { readdir, readFile } from "fs/promises";
import * as path from "path";
import { z, type ZodType } from "zod";

function schemaVaridator<T extends ZodType>(schema: T) {
  return (data: unknown): data is z.infer<typeof schema> => {
    const result = schema.safeParse(data);
    if (!result.success) {
      console.error(result.error.message);
    }
    return result.success;
  };
}
const mySchema = z.object({
  id: z.string(),
  name: z.string(),
  age: z.number().optional(),
});
const mySchemaVaridator = schemaVaridator(mySchema);

async function readFiles(contentPath: string) {
  const files = await readdir(contentPath, {
    encoding: "utf8",
    recursive: true,
  });
  const raw = await Promise.all(
    files.map(async (fileName) => {
      const absolutePath = path.join(contentPath, fileName);
      const file = await readFile(absolutePath, "utf8");
      return JSON.parse(file);
    })
  );
  // => any[]

  const data = raw.filter(mySchemaVaridator);
  return data;
  // => { id: string; name: string; age?: number | undefined }[]
}
```

### next-remote-mdxでMarkdown/MDXファイルを読み込む

next-remote-mdxではRSC (React Server Components)に対応した`next-remote-mdx/rsx` APIが利用できます。ただし`next-remote-mdx/rsc` APIはv4.4.1の時点ではUnstableであるため使用には注意が必要です。

next-remote-mdxは`<MDXRemote>`コンポーネントを使う方法が一般的ですが、`defineMdx`関数ではフロントマターをレンダリングの外部で扱うため`compileMDX`関数を用いています。

```tsx
import { z } from "zod";
import { compileMDX } from "next-mdx-remote/rsc";

const frontmatterSchema = z.object({
  title: z.string(),
  date: z.coerce.date(),
});
type FrontmatterSchema = z.infer<typeof frontmatterSchema>;

async function useMdx(absolutePath: string) {
  const source = await readFile(absolutePath, "utf8");

  const { content, frontmatter } = await compileMDX<FrontmatterSchema>({
    source,
    options: { parseFrontmatter: true },
  });

  return { content, frontmatter };
}
```

`compileMDX`ではジェネリックスを用いてフロントマターの型を指定することができますが、読み込むMarkdown/MDXファイルのフロントマターが望んだ型と一致するとは限らないため、前述のvaridator関数を使って一致しないものを取り除く作業をするといいでしょう。

## まとめ

Next.js App RouterでMarkdown/MDXを単体のページではなくコンテンツソースとして扱う方法が知りたかったのですが情報があまり見つかりませんでした。主要ライブラリの[Contentlayer](https://github.com/contentlayerdev/contentlayer)は[半年以上メンテナンスが止まっており、Next.js v14をサポートしていません](https://github.com/contentlayerdev/contentlayer/issues/429)。

そのような動機でNext.js App Router向けの簡易なコンテンツマネジメントツールを自作してみました。Zodを使う方法はAstroの[コンテンツコレクション](https://docs.astro.build/ja/guides/content-collections/)に着想を得ています。大規模なデータを扱うには不向きかもしれませんが、小規模なサイトの構築には十分機能すると思います。改良点を挙げればキリがありませんが、とりあえず一つの作例として提示しておきます。

サンプルプロジェクト
https://nextjs-mdx-content-management-example.vercel.app/

サンプルプロジェクトのリポジトリ
https://github.com/cieloazul310/nextjs-mdx-content-management-example

next-mdx-remote
https://github.com/hashicorp/next-mdx-remote

Zod
https://zod.dev/

[next-mdx-remote]: https://github.com/hashicorp/next-mdx-remote
[Zod]: https://zod.dev/