React

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月)で廃止され、transformLinkUritransformImageUriは9.0.0(2023年9月)でurlTransformへ統合されました。ラッパーにクラスを当てたい場合は、<Markdown>を自前のdivで囲むか、componentsで各要素に付与します。classNameが効かないという報告の多くは、10.0.0での廃止に古いサンプルが追随していないことが原因です。

GFMのテーブル・タスクリスト表示(remark-gfm)

標準のMarkdownはテーブルや打ち消し線、タスクリストを解釈しません。GitHub Flavored Markdown(GFM)を有効にするにはremark-gfmremarkPluginsへ渡します。「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による許可タグ・属性の定義

既定のサニタイズ設定ではtargetclassなど一部の属性が削られます。必要な属性を通すには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 無害化して描画 ユーザー入力を安全に表示

allowedElementsdisallowedElementsは併用できません。除去せず中身だけ残すならunwrapDisallowedを添えます。生HTMLを一切描画しない要件ならskipHtmlが最も単純で安全です。

カスタムコンポーネントによる見出し・リンク制御

componentsプロップの基本

特定のタグを自前のReactコンポーネントへ差し替えるにはcomponentsを使います。見出しやコードブロックにクラスや独自の挙動を与えたいときの標準手段です。

見出しへのID自動付与(rehype-slug)

見出しへIDを付けるのに自前のカスタムコンポーネントを書く必要はありません。rehype-slugrehypePluginsに渡すと、各見出しにテキスト由来の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でなめらかにできます。

関連記事

資料請求

RELATED POSTS 関連記事