Notes

@next/mdx でポートフォリオに MDX ブログを作った — 型安全な frontmatter と Turbopack のハマりどころ

前回書いた Intercepting Routes の記事、その置き場所として Notes 機能を作った。next-mdx-remote を使わず @next/mdx ネイティブで組み、frontmatter を zod で守り、Turbopack のプラグイン制約を越えるまでの実装記録。今まさに読んでいるこのページの中身の話です。

#Next.js#App Router#MDX#SSG#TypeScript#zod

前回、/djdikeIntercepting Routes でモーダルを作った話 を書いた。だが、そもそも「その記事をどこに載せるのか」が決まっていなかった。

というわけで、ポートフォリオに Notes(このブログ)機能 を作った。この記事はその実装記録だ。つまり 今あなたが読んでいるページそのものの中身 の話になる。

要件はシンプル。

next-mdx-remote ではなく @next/mdx を選んだ

MDX ブログの記事を検索すると、多くは next-mdx-remote を使っている。ファイルを文字列として読み、実行時(またはビルド時)にコンパイルして描画するやり方だ。動くし手軽だが、今回は使わなかった。

代わりに Next.js 公式の @next/mdx で、.mdxモジュールとして動的 import → React Server Component として描画 する方式にした。

@next/mdx(採用)next-mdx-remote
コンパイルビルド時(バンドラが処理)実行時 or ビルド時に自前で
ランタイム MDX ライブラリ不要必要
SSG との相性静的 import なので素直文字列を都度コンパイル
frontmatterremark プラグインで export 化gray-matter で別途パース

決め手は 「ランタイムに MDX ライブラリを持ち込まず、純粋な SSG に落とせる」 こと。App Router / RSC のイディオムにも一番素直だ。next-mdx-remote の本家(hashicorp 版)がメンテ縮小気味なのも背中を押した。

全体構成

app/notes/
├── page.tsx                # 記事一覧(static)
└── [slug]/page.tsx         # 記事詳細(SSG)

content/notes/
└── 2026-07-notes-mdx-blog.mdx   # ← この記事

app/components/mdx/
└── mdxComponents.tsx       # 本文の HTML 要素スタイル(手書き)

lib/
└── notes.ts                # slug 列挙・frontmatter 取得(zod 検証)

mdx-components.tsx          # ← Next.js 規約ファイル(プロジェクト直下・必須)
next.config.ts             # withMDX 設定

ポイントは、記事本体を app/ の外(content/notes/)に置いて、[slug]/page.tsx から動的 import することだ。ページと記事データが分離され、記事を追加するときは .mdx を1枚足すだけで済む。

必須なのは2つの設定ファイル

@next/mdx を App Router で動かすのに要るのは、next.config.ts と、プロジェクト直下の mdx-components.tsx の2つ。

// next.config.ts(抜粋)
import createMDX from "@next/mdx";
 
const nextConfig = {
  // .md/.mdx をページ・import 対象に含める
  pageExtensions: ["ts", "tsx", "md", "mdx"],
};
 
const withMDX = createMDX({ options: { /* 後述 */ } });
export default withMDX(nextConfig);
// mdx-components.tsx(プロジェクト直下・規約ファイル)
import type { MDXComponents } from "mdx/types";
import { mdxComponents } from "@/app/components/mdx/mdxComponents";
 
export function useMDXComponents(components: MDXComponents): MDXComponents {
  return { ...mdxComponents, ...components };
}

mdx-components.tsx は App Router で @next/mdx を使うのに 必須。これが無いと動かない。

置き場所が「プロジェクト直下」なのが地味に間違えやすい。app/ の中ではない。中身は薄い橋渡しに留め、実際のスタイル定義は app/components/mdx/mdxComponents.tsx に寄せた。

ハマりどころ:Turbopack ではプラグインを「文字列」で渡す

シンタックスハイライト(rehype-pretty-code)や GitHub Flavored Markdown のテーブル(remark-gfm)を効かせるには、remark / rehype プラグインを createMDX に渡す。多くの記事はこう書いている。

// これは Turbopack では動かない
import rehypePrettyCode from "rehype-pretty-code";
 
const withMDX = createMDX({
  options: { rehypePlugins: [[rehypePrettyCode, { theme: "github-dark" }]] },
});

Next.js 16 のデフォルトは Turbopack。 この書き方だとプラグインが効かず、ハイライトもテーブルも素の <pre> / <table> のまま出てしまった。

原因は公式ドキュメントに書いてある。

remark and rehype plugins without serializable options cannot be used yet with Turbopack, because JavaScript functions can't be passed to Rust.

プラグイン処理は Rust 側(Turbopack)で走る。そこに JS の関数オブジェクトは渡せない。だから import した関数ではなく、プラグイン名を文字列で 指定し、オプションも シリアライズ可能な値だけ にする必要がある。

// next.config.ts — 正しい書き方(文字列名指定)
const withMDX = createMDX({
  options: {
    remarkPlugins: [
      "remark-gfm",                                  // 記事内のテーブル・打ち消し線
      "remark-frontmatter",                          // YAML frontmatter を認識
      ["remark-mdx-frontmatter", { name: "frontmatter" }], // それを export に変換
    ],
    rehypePlugins: [
      ["rehype-pretty-code", { theme: "github-dark", keepBackground: false }],
    ],
  },
});

remark-gfm を入れているのは、前回の記事で Markdown テーブルを使っていたから。GFM はデフォルトでは有効にならない。

教訓:Next.js 16 でプラグインが「効かない」ときは、まず文字列指定に直す。 import した関数を渡していないか疑う。

frontmatter を型で守る — unknown から zod で絞り込む

@next/mdx は frontmatter を標準サポートしない。そこで remark-frontmatterremark-mdx-frontmatter の順で通し、YAML の frontmatter を frontmatter という named export に変換する。こうすると TypeScript 側から import して読める。

ただし、この export には 型が付かないunknown 相当)。ここで検証を挟まないと、記事の書き間違いが実行時まで潜る。プロジェクトでは Spotify のレスポンスも zod で検証しているので、同じ「unknown → 絞り込み」の方針で守った。

// lib/notes.ts
import { z } from "zod";
 
export const noteFrontmatterSchema = z.object({
  title: z.string(),
  description: z.string(),
  // 文字列比較でソートするため、日付形式を YYYY-MM-DD に固定する
  publishedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "publishedAt は YYYY-MM-DD 形式に"),
  category: z.enum(["engineering", "music", "life"]),
  tags: z.array(z.string()),
  cover: z.string().optional(),
});
 
export type NoteFrontmatter = z.infer<typeof noteFrontmatterSchema>;
 
export async function getNoteFrontmatter(slug: string): Promise<NoteFrontmatter> {
  // コンパイル済み MDX モジュール。frontmatter export は型が付かないので unknown 経由で受ける
  const mod = (await import(`@/content/notes/${slug}.mdx`)) as unknown as {
    frontmatter: unknown;
  };
  return noteFrontmatterSchema.parse(mod.frontmatter);
}

z.inferスキーマから型を導出 しているので、検証ロジックと型が一致し続ける。frontmatter を書き間違えれば parse がビルド時に投げて気づける。日付形式の regex を入れているのは、publishedAt を単純な文字列比較でソートしているから。形式が崩れると並び順が壊れる。

一覧と詳細 — 純 SSG に落とす

詳細ページは、generateStaticParams で全記事を列挙し、dynamicParams = false未定義の slug は 404 にする。これで完全な静的サイトになる。

// app/notes/[slug]/page.tsx(抜粋)
import { getNoteFrontmatter, getNoteSlugs } from "@/lib/notes";
 
export async function generateStaticParams() {
  const slugs = await getNoteSlugs();
  return slugs.map((slug) => ({ slug }));
}
export const dynamicParams = false; // 一覧にない slug は 404 = 純 SSG
 
export default async function NotePage({ params }) {
  const { slug } = await params;
  // MDX モジュールの default が記事本体コンポーネント。
  // mdx-components.tsx のグローバル指定が自動適用されるので components の受け渡しは不要
  const { default: Post } = await import(`@/content/notes/${slug}.mdx`);
  const fm = await getNoteFrontmatter(slug);
 
  return (
    <article>
      <h1>{fm.title}</h1>
      <Post />
    </article>
  );
}

slug を変数にした動的 import(import(`@/content/notes/${slug}.mdx`))は、バンドラが「あり得るモジュール群」をまとめてコンパイル対象にしてくれる。generateStaticParams と組み合わさることで、ビルド時に各記事が事前生成される。next build の出力でも、一覧が ○ (Static)、詳細が ● (SSG) になっているのを確認できた。

記事ごとの OGP

generateMetadata は Server Component と同じく async でデータを取れる。frontmatter から記事ごとの OGP を組み立てる。

export async function generateMetadata({ params }): Promise<Metadata> {
  const { slug } = await params;
  const fm = await getNoteFrontmatter(slug);
  return {
    title: `${fm.title} — M-Tech Design`,
    description: fm.description,
    openGraph: { title: fm.title, type: "article", tags: fm.tags },
    twitter: { card: "summary_large_image", title: fm.title },
  };
}

cover 画像の相対パスを絶対 URL に解決できるよう、ルートの layout.tsxmetadataBase を一度だけ設定しておくと、各記事はパスを書くだけで済む。

本文スタイル — typography プラグインは入れなかった

@tailwindcss/typographyprose クラス)を使えば見出しや段落が一発で整う。だが今回は入れず、mdxComponents各 HTML 要素を手書き した。既存サイトのブランドカラー(シアン / イエロー)や罫線トークンを本文にもそのまま効かせて、世界観を揃えたかったからだ。

その中で一つだけ工夫が要ったのが、インラインコードとコードブロックの出し分けrehype-pretty-code はブロックコードの <code>data-language 属性を付ける。その有無で判別できる。

// app/components/mdx/mdxComponents.tsx(抜粋)
code: (props) =>
  "data-language" in props ? (
    // ブロック(rehype-pretty-code が装飾済み)はそのまま素通し
    <code {...props} />
  ) : (
    // インラインは自前のピル装飾
    <code className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-brand-cyan" {...props} />
  ),

これをやらないと、ハイライト済みブロックの中の文字まで「インラインのピル装飾」が乗ってしまう。属性ひとつで綺麗に分岐できた。

まとめ

これで「学んだことを書く場所」ができた。次に何か作ったら、またここに書く。