ブログ記事をMDXで管理したくて、next-mdx-remote-clientを導入した話
ブログ記事を Markdown で書いていると、「ここだけReactコンポーネントを差し込みたい」「コードブロックをサイトのデザインに合わせたい」と思う瞬間があります。
そこで出会ったのが MDX。
さらに Next.js の App Router に合わせやすいのが next-mdx-remote-client でした。
この記事では、私がこのライブラリを選んだ理由、実装手順などを、microCMS連携&静的生成(SG)構成という実戦的な環境で解説します。
なぜ MDX を導入したか
当初は microCMS のリッチエディタで記事管理をしようとしていましたが、以下のような課題がありました。
- 特定箇所にだけデザイン済みの UI 部品を入れたいのに、HTML直書きか画像で代用するしかない
- コードブロックのハイライトや装飾を記事ごとに調整するのが面倒
- OGPカードやカスタム埋め込み(Tweet、YouTubeなど)の統一が難しい
このように、Markdown はシンプルで扱いやすいですが、インタラクティブ要素や高度なレイアウトの埋め込みには不向きでした。
一方で、MDX はMarkdownにReactコンポーネントを埋め込めるので、こういった拡張が容易になります。たとえば、記事中に \<OgpCard url="..." />
と書くだけで、スタイルも機能も統一されたリンクカードを呼び出せます。
なぜ next-mdx-remote-client を選んだのか
MDXをNext.jsで扱う方法は以下のようにいくつかあります。
方法 | 特徴 | 弱点 |
---|---|---|
@next/mdx | App Routerに組み込みやすい | サーバーでの動的パースが難しい |
next-mdx-remote | サーバーでシリアライズし、クライアントで描画可能 | App Router対応に工夫が必要 |
自前 (@mdx-js/mdx ) | 自由度MAX | 設定が煩雑、保守コスト高 |
next-mdx-remote-client
はApp Routerに対応しており、サーバーでパース → クライアントで描画という流れをシンプルに作れます。components
の差し替えも容易で、記事ごとに動的なUIを組み込みやすいのが魅力です。
今回の構成では App Router + SSG + microCMS という要件だったため、ビルド時に microCMS から記事を取得 → MDX としてシリアライズ → 静的ページ生成 という流れに最も適していた next-mdx-remote-client
を選びました。
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.com
技術スタック
主に以下の技術を使用して、本サイトを作成しました。
- フロント:Next.js[App Router](React/TypeScript)
- デザイン:TailwindCSS、vanilla-extract
- ライブラリ:next-mdx-remote-client
- コンテンツ管理:microCMS
- デプロイ:Vercel
導入手順
Next.js[App Router] のプロジェクトは作成済みとして、進めていきます。
ライブラリのインストール
npm install next-mdx-remote-client remark-gfm open-graph-scraper
remark-gfm
:GitHub Flavored Markdown(表・チェックボックスなど)対応open-graph-scraper
:OGPデータ取得
MDX 変換オプションの設定
import type { MDXRemoteOptions } from "next-mdx-remote-client/rsc";
import remarkGfm from "remark-gfm";
export const mdxOptions: MDXRemoteOptions = {
mdxOptions: {
remarkPlugins: [remarkGfm],
format: "mdx",
},
parseFrontmatter: true,
};
MDXRemote
(RSC版)に渡す変換オプションを事前定義。remark-gfm
で GFM(表/チェックボックス/打消し等) を有効化。format: "mdx"
により、本文内の JSX(MDX)構文を許可。parseFrontmatter: true
で、先頭の---
で囲うFrontmatterをsource.frontmatter
に抽出
ルーティングと静的生成(SG)/ データ取得
動的ルートの静的化(generateStaticParams
)
export async function generateStaticParams() {
const slugs = await getAllBlogSlugs();
return (slugs ?? []).map(({ articleSlug }) => ({ slug: articleSlug }));
}
- ビルド時に microCMS からすべての
slug
を取得 → 各記事ページをSGで生成
ページ本体(RSC) + OGP 前処理 + MDX描画
import React from "react";
import { getAllBlogSlugs, getBlogBySlug } from "@/libs/microcms";
import { notFound } from "next/navigation";
import { fetchOgpDataFromMdx } from "@/libs/fetchOgpDataFromMdx";
import { MdxComponents } from "@/components/PortfolioAndBlog/ContentPage/MdxComponents";
import { MDXRemote } from "next-mdx-remote-client/rsc";
import { mdxOptions } from "@/libs/mdxOptions";
import BlogClientWrapper from "@/components/PortfolioAndBlog/ContentPage/BlogClientWrapper";
type PageProps = {
params: Promise<{ slug: string }>;
};
export async function generateStaticParams() {
const slugs = await getAllBlogSlugs();
return (slugs ?? []).map(({ articleSlug }) => ({ slug: articleSlug }));
}
export default async function BlogContentPage({ params }: PageProps) {
const { slug } = await params;
const blogContentData = await getBlogBySlug(slug);
// 存在しなければ 404
if (!blogContentData) return notFound();
// Markdown → MDX に変換しつつ、OGPも取得
const { ogpDataList } = await fetchOgpDataFromMdx(blogContentData.body);
const components = MdxComponents(ogpDataList);
return (
<BlogClientWrapper blogContent={blogContentData}>
<MDXRemote
source={blogContentData.body}
options={mdxOptions}
components={components}
/>
</BlogClientWrapper>
);
}
getBlogBySlug(slug)
で RSC側(サーバー)でデータ取得fetchOgpDataFromMdx()
で本文から OGPカードのURL を抽出 → ビルド/サーバーで OGPデータ を取得(クライアントで fetch しない)MdxComponents(ogpDataList)
:取得したOGPデータのマップを差し込んだコンポーネント辞書MDXRemote
(RSC版)にsource
(MDX文字列)・options
・components
を渡して描画
MDX コンポーネント辞書
上記の components={components}
には、下記のようにhtml変換後の各要素に対応するカスタムコンポーネントをマッピングしたオブジェクトを渡します。
import React from "react";
import type { MDXComponents } from "next-mdx-remote-client/rsc";
import { OgpCard } from "@/components/PortfolioAndBlog/ContentPage/MdxComponents/OgpCard";
import * as styles from "./style.css";
type OgpCardProps = React.ComponentProps<typeof OgpCard>;
export const MdxComponents = (
ogpMap: Record<string, OgpCardProps["ogp"]>
): MDXComponents => ({
// ---- MDX全体のラッパー ----
wrapper: ({ children }) => <div className={styles.wrapper}>{children}</div>,
// ---- 見出し ----
h2: (props) => <h2 className={styles.h2} {...props} />,
h3: (props) => <h3 className={styles.h3} {...props} />,
h4: (props) => <h3 className={styles.h4} {...props} />,
// ---- テキスト要素 ----
p: (props) => <p className={styles.p} {...props} />,
strong: (props) => <strong className={styles.strong} {...props} />,
// ---- OGPカード ----
OgpCard: ({ url }: { url: string }) => {
const ogp = ogpMap[url];
if (!ogp) return null;
return <OgpCard ogp={ogp} />;
},
});
wrapper
で本文全体のレイアウトや余白を統一- MDX の標準タグ(
h2
/h3
/p
/...)やカスタムタグ(<OgpCard />
)を、見た目/機能つきの React に差し替え \<OgpCard url="...">
は、事前収集したogpMap[url]
を引き当てて描画
実装のポイント
上記の実装により、ビルド時またはサーバーサイドでコンテンツの変換とOGP情報の取得が完結し、クライアントでは描画専用の軽量な処理だけを行えるため、パフォーマンス向上と安定した表示が両立できます。
また、以下のようなメリットもあります。
- MDX により記事中に UI 部品を安全に再利用できる
- Server Component でパースし、Client Component で描画 → App Router の構造に沿った役割分離
- OGP情報をビルド/サーバーで確定 → Hydrationエラー回避に強い
- microCMS 側はプレーン Markdown 保持 → エンジニアフレンドリーなコンテンツ管理
まとめ
next-mdx-remote-client
は App Router 環境で Markdown を超える表現力 を記事に与えつつ、SSG との相性も非常に良いライブラリだと思いました。Markdown に React の力を組み合わせたい方には特におすすめです。
もし同じように App Router 環境で MDX を導入したいと考えている方は、ぜひ今回の実装例を参考にしてみてください!
最後に
ここまで読んでいただきありがとうございました!
本記事が、MDX で記事管理してみたいという方の参考になれば嬉しいです。
なお、本サイトのソースコードは以下で公開していますので、よければ覗いてみてください!
GitHub - miyazaki-dev01/My_profile: プロフィールサイト(React[Next.js]/TypeScript)
プロフィールサイト(React[Next.js]/TypeScript). Contribute to miyazaki-dev01/My_profile development by creating an account on GitHub.
github.com