MDX 実装テクニック
MDX でドキュメント / 技術記事を書くための実装ノート。Shiki によるコードハイライト(dual theme / 差分表示 / 行ハイライト)、MDX に外部コンポーネントを注入するパターン(components prop / scope)までを 1 ページに統合。
MDX × Shiki でコードブロックを整える
Astro 6.x の MDX と Shiki の組み合わせで、技術記事のコードブロックを VSCode 同等の精度で見せるための設定パターン。dual テーマ / ファイル名タブ / 行ハイライト / diff / inline code を実装観点で整理。
検証日: 2026-05-09
使用バージョン:
shiki@1.x/astro@6.3.0/@astrojs/mdx@5.0.3環境: Node 22.12 / pnpm 10
想定読者: Astro / Next.js / Docusaurus で MDX を書いている、コードブロックの見た目を整えたい人
技術記事の 半分はコードブロック。コードブロックの見た目で、記事の信頼度がはっきり変わります。 Shiki は VSCode と同じシンタックスハイライタで、TextMate grammar ベースの精度の高さが強みです。
なぜ Shiki にしたか
別の選択肢(Prism / highlight.js / Starry Night)と比べた時、Shiki が選ばれる理由:
- VSCode と同じ grammar で言語精度が高い(TypeScript の細かい型構文や JSX 内ネストにも強い)
- dual テーマ(light / dark)を 1 回のレンダリングで両対応 できる(色を CSS 変数として埋め込み、
prefers-color-schemeで切替) - Astro が標準で組み込んでいる(
astro.config.mjsにshikiConfigを渡すだけ)
逆に、ランタイムでハイライトする(LLM 出力をその場で表示するなど)用途では、shiki/wasm の bundle サイズが課題になることも(後述)。
最小設定(Astro 6.x + MDX)
Astro 標準の shikiConfig に dual theme(light + dark)と defaultColor: false を指定するだけで、コードブロックに 2 色分の inline style 変数が埋まる:
import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
const shikiConfig = {
themes: { light: "github-light", dark: "github-dark" },
wrap: true,
};
export default defineConfig({
integrations: [
mdx({ shikiConfig }),
],
markdown: { shikiConfig },
});
デュアルテーマ(自動切替)
themes: { light: ..., dark: ... } を指定すると、Astro / Shiki は <pre style="--shiki-light: #...; --shiki-dark: #..."> の形で 両テーマ分の色 CSS 変数を埋め込みます。
CSS 側で prefers-color-scheme に応じて変数を上書きすれば、自動的にダークモード対応になる:
@media (prefers-color-scheme: dark) {
pre, code {
color-scheme: dark;
}
}
class 切り替えで dark mode を制御するなら defaultColor: false を指定して、自前 CSS で --shiki-dark を有効化する処理を書きます。
ファイル名タブ
fence の言語名の後に title="..." を付けると、shiki が data-filename 属性を出してくれる。CSS で「タブ風」表示に整える:
import { sql } from "./client";
export async function findUser(id: string) {
return sql`SELECT * FROM users WHERE id = ${id}`;
}
上の例は title="src/lib/db.ts" を info string(言語の後ろに半角スペース)で書いています。Astro / Next.js / Docusaurus いずれもこの記法を概ねサポート。
js:filename.js のようにコロン区切りで書く流派もありますが、MDX v3 ベースでは title="..." のほうが portable。
差分表示(diff)
```diff fence で 行頭 + / - が緑 / 赤に色付く。before/after の対比をコードで書ける:
- const result = await fetch(url);
+ const result = await fetch(url, { signal: controller.signal });
```` の後ろに diff を指定するだけで + / - の行が緑 / 赤に着色されます。
コードブロックの diff として使うのは「小さなスニペットの before/after」が中心。長大な diff は GitHub のリンクに貼ったほうが読みやすい。
行ハイライト(rehype 拡張)
特定の行を強調する記法は Astro 標準には組み込まれていません(rehype プラグインが必要)。
推奨は expressive-code(Astro 専用 integration あり)で、ファイル名タブ + 行ハイライト + 差分を統合的に扱えます。
// 例: expressive-code を使う場合の astro.config.mjs(抜粋)
import expressiveCode from "astro-expressive-code";
import mdx from "@astrojs/mdx";
export default defineConfig({
integrations: [
expressiveCode({ themes: ["github-light", "github-dark"] }),
mdx(), // ← expressive-code の **後** に置く
],
});
inline code(highlight 付き)
backtick の中で {:言語名} を付けるとインラインも shiki でハイライトされる(地の文中の短いコード片に効果的):
インラインで {`const x: number = 1`} のように書ける。
Astro の <Code> コンポーネントを使えば確実にハイライトされる:
---
import { Code } from "astro:components";
---
<Code code="const x: number = 1" lang="ts" inline />
API リファレンスのような inline code を多用する記事では、専用 component を 1 つ作っておくと一貫性が出ます。
つまずいたポイント
mdx({ shikiConfig })とmarkdown.shikiConfigのどちらかを忘れる —.mdと.mdxで見た目が違う事故が起きる。両方に同じ config を渡す- CSS 変数の prefix を変えると Astro 標準のダークモード切替が壊れる —
--shiki-prefix は変えない - 大きいコードブロックで build が遅くなる — Shiki は build 時にレンダリング。数千行貼ると build が重くなるので、抜粋 + 「全文は別 link」 に分割する
- 言語指定子のタイポは無音で fallback する —
js/javascriptは OK だが、javascは灰色になる(エラーにならない) - VSCode の表示と若干違う — VSCode は別 grammar を併用していることがある。完全一致は期待しない
評価
| 観点 | 評価 | コメント |
|---|---|---|
| 言語精度 | ◎ | VSCode と同じ grammar、TypeScript / JSX に強い |
| 設定の手軽さ | ○ | Astro / Next.js では config 数行で動く |
| dual テーマ | ◎ | CSS 変数経由で 1 回の build で両対応 |
| ランタイム性能 | △ | build 時レンダリングが基本、ランタイムは bundle 重め |
| エコシステム | ○ | rehype-pretty-code / expressive-code など補助プラグインが揃う |
向く / 向かないケース
- 向く: MDX/MD ベースの技術記事、ドキュメントサイト、ブログ、書籍
- 向かない: ランタイム生成コード(LLM 出力など)→ shiki/wasm を browser ロードする選択肢はあるが、bundle サイズに注意
- 向かない: 数千行級のコードブロック → 抜粋 + link or embed gist のほうが読まれる
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
Effective TypeScript(第2版) : 型システムの力を最大限に引き出す83項目
急速に普及が進んでいるTypeScriptの実用書! TypeScriptの実用書。TypeScript…
TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発
新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…
かんたん TypeScript
本書は、「広く・正しく・新しく」をコンセプトにTypeScriptでプログラミングをはじめるにあたって基本的なことはすべて学習できる内容となっています。また、イラストによる図解方式で概念をやさしく解説している…
ゼロからわかる TypeScript入門
プロを目指す人のためのTypeScript入門 安全なコードの書き方から高度な型の使い方まで
TypeScriptは、JavaScriptに静的型付けの機能を加えたオープンソースのプログラミング言語です。本書では、根幹となるJavaScr…
MDX に Astro / React コンポーネントを組み込む実装パターン
MDX への component 注入の 4 つの流儀 — frontmatter import、components prop、グローバル MDX 拡張(remark/rehype)、Provider による context 共有。Astro と React Island の使い分けと、よく詰まる SSR/hydration 問題を実装メモ化。
検証日: 2026-05-10
使用バージョン:
@astrojs/mdx@5.0.x/astro@6.3.x対象: MDX で素朴な markdown は書けた、components を埋め込んで動的記事にしたい人
MDX = Markdown 構文の中に JSX(component)を直接書けるフォーマット。Astro では .mdx ファイルが Astro / React コンポーネントの両方を受け入れる。本稿は 何種類かある「component の流し込み方」 を整理する。
1. frontmatter から import(最頻出)
.mdx の冒頭で他のファイルから component を import すれば、そのまま JSX として使える。
---
title: "..."
---
import Callout from "@/components/Callout.astro";
import { MyChart } from "@/components/MyChart.tsx";
# 記事タイトル
<Callout type="warn" title="重要">
本文中で出した警告。
</Callout>
<MyChart client:load />
ポイント:
.astroも.tsxも import 可- React 系は hydration directive が必要(
client:load/client:visible/client:idle/client:only="react") - path alias(
@/components)がtsconfig.jsonのpathsで動く - import 文は frontmatter の 後、本文の前 に書く
2. components prop で「グローバル差し替え」
特定の HTML タグ(a / h2 / code / img 等)を 記事ごとに統一して上書き したい場合は、render 時の components prop を使う。
---
// src/pages/articles/[...slug].astro
import { getCollection, render } from "astro:content";
import BookLink from "@/components/BookLink.astro";
import CustomLink from "@/components/CustomLink.astro";
const { entry } = Astro.props;
const { Content } = await render(entry);
---
<Content
components={{
BookLink, // MDX で <BookLink /> を有効化
a: CustomLink, // すべての <a> を CustomLink に差し替え
code: SmartInlineCode, // インラインコードを別 component に
}}
/>
ポイント:
- HTML タグ名 lowercase で markdown が出力する要素を上書き(
a,h1-h6,code,pre,img,table, etc.) - PascalCase 名で MDX の
<BookLink />を有効化(import なしでも使える) - 記事側で都度 import するより楽だが、grep ability(検索可能性)が下がるので使い分け
3. 全 MDX に共通注入(MDXProvider 風 / Astro での代替)
MDX 公式の MDXProvider は Astro では使わない代わりに、全 MDX で共通の wrapper を作る:
---
// src/components/MdxArticle.astro
import BookLink from "@/components/BookLink.astro";
import Callout from "@/components/Callout.astro";
const { Content } = Astro.props;
const components = { BookLink, Callout };
---
<article class="prose">
<Content components={components} />
</article>
<!-- 使用 -->
<MdxArticle Content={entry.Content} />
これで BookLink / Callout は全 MDX で import 不要に使える。記事側のフロントマター import を簡素化できる。
4. remark / rehype プラグインで「構文レベル」拡張
文中で繰り返す表記(例:{{book:isbn}} を BookLink に変換、@user を mention に変換)は remark プラグイン で AST 操作する:
// astro.config.mjs
import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
import { visit } from "unist-util-visit";
function remarkBookShortcode() {
return (tree: any) => {
visit(tree, "text", (node, index, parent) => {
const match = node.value.match(/\{\{book:([0-9]+)\}\}/);
if (!match) return;
const isbn = match[1];
// text node を JSX 要素に置換
parent.children.splice(index, 1, {
type: "mdxJsxFlowElement",
name: "BookLink",
attributes: [{ type: "mdxJsxAttribute", name: "isbn", value: isbn }],
children: [],
});
});
};
}
export default defineConfig({
integrations: [mdx({ remarkPlugins: [remarkBookShortcode] })],
});
これで {{book:9784297127473}} と書くだけで <BookLink isbn="9784297127473" /> に変換される。長文記事で同じパターンを大量に書く場合にだけ採用(maintain コストとの trade-off)。
5. SSR vs Client(hydration)
| directive | タイミング | 用途 |
|---|---|---|
| なし(.astro 内) | SSR のみ、JS なし | 静的表示 |
client:load | mount 直後 hydrate | 即座にインタラクション必要 |
client:visible | viewport 入った時 | 下にある interactive demo |
client:idle | アイドル時 | 重要度低めの enhancement |
client:only="react" | SSR せずに client 側だけ | window / leaflet / dnd-kit 等 |
client:only="react" を選ぶ典型ケース:
window/documentを module 評価時に触る(leaflet / 一部 d3)useSortableの aria 属性が SSR と client で違って hydration mismatchuuid()のランダム値をuseState初期値で呼ぶlocalStorageに依存する初期 state
6. .mdx と .astro の相互運用
.astro から .mdx の Content を呼び出せるし、.mdx から .astro を import もできる。
---
title: "..."
---
import StaticAstroComponent from "@/components/Static.astro";
import InteractiveReactComponent from "@/components/Interactive.tsx";
<StaticAstroComponent /> <!-- SSR、追加 JS なし -->
<InteractiveReactComponent client:visible /> <!-- viewport で hydrate -->
実装ルール:
- 静的要素は
.astro(BookLink / Callout / Sparkline 等) - 状態管理が必要な要素は
.tsx(form / editor / chart) - どちらでも書ける時は
.astro優先(bundle 削減)
7. MDX 内で動かない / 詰まる構文
MDX で「型の説明」「テンプレ literal の説明」を書く時は、以下の構文がトラブルになる:
{...}(波括弧) = MDX が JSX expression と解釈する。fenced code block の中なら安全、外で書きたい時は HTML entity({})へ<を含む文字列(generic / HTML タグの説明)= JSX タグ開始と誤認。バッククォート inline code で囲むか HTML entity に<Type<T>>のような generic = inline code(バッククォート)の中なら OK、地の文では破綻する>5 連続 = blockquote と誤認(あまり起きないが SQL の例で要注意)
つまずいたポイント
<Callout>を使ったらCallout is not defined:frontmatter の import 文を書き忘れ。component を使う前に import が必須componentsprop でaを上書きしたら全部 onClick が壊れる:CustomLink 内でhref/class/ その他の prop を spread 渡し忘れ。<a {...props} />で透過する- remark プラグインを書いたら build が遅くなった:すべての text node を visit するため。AST 操作は 必要な node 型に絞る(
visit(tree, "text", ...)ではなく具体的な条件で早期 return) client:only="react"で children が消える:Astro の component を子に渡せない、React tree のみclient:loadを書いたのに動かない:framework="react"integration が足りていない、@astrojs/reactを install + integrations に追加
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
Effective TypeScript(第2版) : 型システムの力を最大限に引き出す83項目
急速に普及が進んでいるTypeScriptの実用書! TypeScriptの実用書。TypeScript…
TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発
新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…
かんたん TypeScript
本書は、「広く・正しく・新しく」をコンセプトにTypeScriptでプログラミングをはじめるにあたって基本的なことはすべて学習できる内容となっています。また、イラストによる図解方式で概念をやさしく解説している…
ゼロからわかる TypeScript入門
プロを目指す人のためのTypeScript入門 安全なコードの書き方から高度な型の使い方まで
TypeScriptは、JavaScriptに静的型付けの機能を加えたオープンソースのプログラミング言語です。本書では、根幹となるJavaScr…
次に読むガイド
- 2 セクション統合
Zod v4 スキーマ実装ガイド
Zod v4 でランタイム + 型レベルのバリデーションを書くパターン。基本のスキーマ定義から transform / refinement / discriminated union / メッセージ国際化までを 1 ページに統合。
- 2 セクション統合
TypeScript 上級活用ガイド
TypeScript の踏み込んだ型テクニック(conditional types / template literal types / branded types など)と、v6 → v7 のメジャーアップグレード時に当たる主要な変更点・移行ノートを 1 ページに統合。実コード付き。