react-markdownの使い方|GFM表示とrehype-sanitizeで安全にMarkdownを描画する
react-markdownは、Markdown文字列をdangerouslySetInnerHTMLを使わずにReactの要素ツリーへ変換して描画するライブラリです。生成AIの出力やCMSの本文をそのまま画面に出す場面で、XSSを避けながらMarkdownを表示できます。この記事では、react-markdown v10(10.0.0は2025年2月公開)を前提に、インストールからGFMのテーブル表示、rehype-sanitizeによる安全なHTML描画、カスタムコンポーネント、見出しへジャンプする目次までを最小コードで整理します。ReactとNext.jsの関係を先に押さえたい場合はNext.jsとNuxtの違いを比較|React・Vueとの関係と選び方も参照してください。
まとめ:react-markdown運用の要点
- インストールは
npm install react-markdown。テーブルなどGFMを使うならremark-gfmを併せて入れ、remarkPluginsに渡す。 - react-markdownは既定でHTMLをエスケープするため安全。生HTMLを描画するときだけ
rehype-rawを使い、必ずrehype-sanitizeと組み合わせて無害化する。 - v10.0.0で
classNameプロップが廃止。スタイルは外側の要素かcomponentsで当てる。 - 見出しのIDは
rehype-slugで自動付与でき、そのIDへ#リンクでジャンプする目次を作れる。
以下で各手順とコードを順に説明します。
react-markdownとは:仕組みと採用理由
react-markdownはMarkdown文字列を解析し、いったんhast(HTMLの抽象構文木)へ変換してからReact要素として出力します。文字列をHTMLとして直接DOMへ挿入しないため、dangerouslySetInnerHTMLで起きがちなスクリプト注入(XSS)を構造的に避けられるのが最大の採用理由です。remark/rehypeのプラグイン機構に対応し、GFMやサニタイズ、見出しID付与などを組み合わせて拡張できます。v10はESMとReact 18以降を前提とする点に注意してください。
インストールと基本的な表示
インストールコマンド
本体に加え、テーブルやタスクリストを扱うためのGFMプラグイン、後述の安全なHTML描画に使うプラグインをまとめて導入します。
npm install react-markdown remark-gfm rehype-sanitize rehype-raw rehype-slug
最小構成でよければreact-markdown単体でも動きます。追加機能が要るときにプラグインを足す方針が扱いやすいです。
最小コードでのMarkdown描画
Markdown文字列をchildrenとして渡すだけで描画できます。
import Markdown from 'react-markdown';
export default function MarkdownView({ source }) {
return <Markdown>{source}</Markdown>;
}
Next.jsのApp Routerでは、Markdownの整形にNode APIを使わないこのコンポーネントはServer / Client どちらでも配置できます。
バージョン差の落とし穴(className廃止・urlTransform統合)
旧記事や古いサンプルの多くはv8以前の書き方で、いくつかのプロップがバージョンごとに姿を変えています。トップレベルのclassNameプロップは10.0.0(2025年2月)で廃止され、transformLinkUri・transformImageUriは9.0.0(2023年9月)でurlTransformへ統合されました。ラッパーにクラスを当てたい場合は、<Markdown>を自前のdivで囲むか、componentsで各要素に付与します。classNameが効かないという報告の多くは、10.0.0での廃止に古いサンプルが追随していないことが原因です。
GFMのテーブル・タスクリスト表示(remark-gfm)
標準のMarkdownはテーブルや打ち消し線、タスクリストを解釈しません。GitHub Flavored Markdown(GFM)を有効にするにはremark-gfmをremarkPluginsへ渡します。「react-markdownでテーブルが表示されない」原因のほとんどはこのプラグイン未導入です。
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
<Markdown remarkPlugins={[remarkGfm]}>{source}</Markdown>
これで| 列 | 列 |記法がHTMLのtableとして描画されます。テーブルの見た目は、生成されるtable要素にCSSを当てて整えます(枠線・ゼブラ・横スクロールなど)。react-markdownはインラインスタイルを付けないため、スタイリングはアプリ側のCSSで完結させます。
rehype-sanitizeによる安全なHTML表示(XSS対策)
なぜdangerouslySetInnerHTMLではなくreact-markdownか
ユーザー投稿や外部データをそのままdangerouslySetInnerHTMLで挿入すると、<script>やonerror属性経由でスクリプトが実行されるXSSの入口になります。react-markdownは既定でMarkdown内の生HTMLをエスケープするため、この既定のままなら安全です。XSSとCSRFの違いや実装レベルの対策はXSSとCSRFの違いとは?仕組み・被害・対策を実装レベルで徹底比較で整理しています。
生HTMLの許可と無害化(rehype-raw + rehype-sanitize)
Markdown内に書かれた生HTMLを描画したい場合はrehype-rawで解析を有効化します。ただしこれ単体は無防備なので、必ずrehype-sanitizeを後段に置いて危険なタグ・属性を除去します。プラグインは配列の順に適用されるため、raw→sanitizeの順序が重要です。
import Markdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
<Markdown rehypePlugins={[rehypeRaw, rehypeSanitize]}>
{source}
</Markdown>
これで<script>やonclickのような危険な要素・属性は落とされ、安全なHTMLだけが残ります。
schemaによる許可タグ・属性の定義
既定のサニタイズ設定ではtargetやclassなど一部の属性が削られます。必要な属性を通すにはdefaultSchemaを複製して拡張し、rehype-sanitizeへ渡します。
import { defaultSchema } from 'rehype-sanitize';
const schema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
a: [...(defaultSchema.attributes.a ?? []), 'target', 'rel'],
},
};
<Markdown rehypePlugins={[rehypeRaw, [rehypeSanitize, schema]]}>
{source}
</Markdown>
ホワイトリスト方式なので、明示的に許可した要素・属性以外は通りません。信頼できない入力ほど許可範囲を最小限に保つのが安全です。
HTMLの許可・制限手段の使い分け
react-markdownはHTMLの扱いを複数の手段で制御できます。入力の信頼度に応じて次のように選び分けます。信頼できない入力にはサニタイズか無視を、信頼できる管理者入力にだけ生HTMLを許可します。
| 手段 | 挙動 | 使う場面 |
|---|---|---|
skipHtml |
生HTMLを無視 | 最も安全・HTML不要なとき |
disallowedElements |
指定タグを除去 | 数タグだけ禁止したい |
allowedElements |
指定タグのみ許可 | ホワイトリスト運用 |
rehype-raw単体 |
生HTMLをそのまま描画 | 信頼できる入力限定・危険 |
rehype-raw+rehype-sanitize |
無害化して描画 | ユーザー入力を安全に表示 |
allowedElementsとdisallowedElementsは併用できません。除去せず中身だけ残すならunwrapDisallowedを添えます。生HTMLを一切描画しない要件ならskipHtmlが最も単純で安全です。
カスタムコンポーネントによる見出し・リンク制御
componentsプロップの基本
特定のタグを自前のReactコンポーネントへ差し替えるにはcomponentsを使います。見出しやコードブロックにクラスや独自の挙動を与えたいときの標準手段です。
見出しへのID自動付与(rehype-slug)
見出しへIDを付けるのに自前のカスタムコンポーネントを書く必要はありません。rehype-slugをrehypePluginsに渡すと、各見出しにテキスト由来のIDが自動で付きます。
import rehypeSlug from 'rehype-slug';
<Markdown rehypePlugins={[rehypeSlug]}>{source}</Markdown>
付与されたIDは目次リンクやページ内アンカーの遷移先として使えます。
外部リンクの別タブ・rel付き制御
リンクを外部だけ別タブで開き、relで参照元漏れとタブナビングを防ぐには、a要素を差し替えます。
<Markdown
components={{
a({ href, children }) {
const external = href?.startsWith('http');
return (
<a
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
>
{children}
</a>
);
},
}}
>
{source}
</Markdown>
下線や強調などの装飾は、差し替えたコンポーネント側でクラスを当てて表現します。
見出しへジャンプする目次の実装
見出しIDの収集と目次生成
rehype-slugでIDを付けたうえで、描画後の見出しを走査すれば目次を組み立てられます。
// 描画後に見出しからIDと文言を集める
const nodes = document.querySelectorAll('h2[id], h3[id]');
const toc = Array.from(nodes).map((el) => ({
id: el.id,
text: el.textContent,
level: el.tagName,
}));
集めた配列をa href="#id"のリンク一覧として並べれば、そのまま目次になります。
アンカーへのスムーズ遷移
ページ内アンカー(html ジャンプ)の遷移をなめらかにするには、CSSのscroll-behaviorだけで足ります。
html {
scroll-behavior: smooth;
}
固定ヘッダーがある場合は、遷移先が隠れないよう見出しにscroll-margin-topを付けて調整します。
よくある質問
react-markdownのインストールコマンドは?
npm install react-markdownです。テーブルなどGFMを使うならnpm install react-markdown remark-gfmのようにremark-gfmも併せて導入します。
react-markdownでテーブルが表示されないのはなぜ?
標準Markdownはテーブルを解釈しないためです。remark-gfmを導入しremarkPlugins={[remarkGfm]}を指定すると、tableとして描画されます。
react-markdownで生HTMLを安全に表示するには?
rehype-rawで生HTMLの解析を有効にし、後段にrehype-sanitizeを置いて無害化します。信頼できない入力ならskipHtmlでHTML自体を無視するのが最も安全です。
classNameプロップが効かないのはなぜ?
10.0.0(2025年2月)でトップレベルのclassNameが廃止されたためです。<Markdown>をdivで囲むか、componentsで各要素にクラスを当ててください。
見出しにジャンプするアンカーを付けるには?
rehype-slugで見出しにIDを自動付与し、そのIDへ#付きリンクを張ります。遷移はCSSのscroll-behavior: smoothでなめらかにできます。