【Next.js × ヘッドレスWordPress】Markdownの表崩れ・クォート文字化け・目次key重複・シンタックスハイライトを完全解決する手順

公開日: 2026年08月14日

WordPress をヘッドレス CMS として運用し、フロントエンドに Next.js(App Router / SSG) を採用して Markdown で記事を配信する構成において、実際に運用・移行する中でいくつかの Markdown レンダリングの落とし穴 に直面しました。

具体的には以下の 4 つの問題です:

  1. 📊 Markdown の表(Table)が HTML に変換されず、パイプ記号(|)のまま表示される
  2. ⚠️ 日本語見出しの記事で Encountered two children with the same key, "" という React key 重複エラーが発生する
  3. 🔤 コードブロック内のクォート('")が '" と表示されてしまう
  4. 🎨 コードブロックに言語別のシンタックスハイライト(色付け)が効いていない

今回は、これらの原因と remark / rehype パイプラインおよび CSS を使ったスマートな完全解決手順 を備忘録としてまとめます。


🔍 問題 1: Markdown の表(Table)が表示されない・崩れる

発生していた現象

WordPress から GraphQL 経由で取得した Markdown に表(| ヘッダー | ... |)が含まれていても、ブラウザ上では <table> タグにならず、<p> タグのプレーンテキストとしてそのまま文字が表示されてしまっていました。

原因

デフォルトの remark-parseCommonMark 仕様 に準拠しているため、標準の CommonMark に含まれない GFM(GitHub Flavored Markdown)の表構文 を解析できず、ただのテキスト段落として扱われていました。

解決策: remark-gfm の追加とテーブルスタイリング

  1. remark-gfm をインストール:

    pnpm add remark-gfm
    
  2. Markdown 処理パイプライン(src/lib/markdown.ts)に .use(remarkGfm) を追加:

    import { remark } from 'remark';
    import remarkParse from 'remark-parse';
    import remarkGfm from 'remark-gfm';
    import remarkRehype from 'remark-rehype';
    import rehypeSlug from 'rehype-slug';
    import rehypeStringify from 'rehype-stringify';
    
    const result = await remark()
      .use(remarkParse)
      .use(remarkGfm) // ★ GFM(表、取り消し線、タスクリスト等)を有効化
      .use(remarkRehype)
      .use(rehypeSlug)
      .use(rehypeStringify)
      .process(markdownContent);
    
  3. テーブルの見た目とモバイル対応(横スクロール)を globals.css に追加:

    /* Markdown Table Styles */
    .prose table {
      width: 100%;
      border-collapse: collapse;
      margin-top: 1.5rem;
      margin-bottom: 1.5rem;
      font-size: 0.95rem;
      display: block;
      overflow-x: auto;
      -webkit-overflow-scrolling: touch;
    }
    
    @media (min-width: 768px) {
      .prose table {
        display: table;
      }
    }
    
    .prose thead {
      background-color: #f8fafc;
      border-bottom: 2px solid #cbd5e1;
    }
    
    .prose th {
      padding: 0.75rem 1rem;
      font-weight: 600;
      border: 1px solid #e2e8f0;
      color: #1e293b;
      white-space: nowrap;
    }
    
    .prose td {
      padding: 0.75rem 1rem;
      border: 1px solid #e2e8f0;
      color: #334155;
    }
    
    /* Markdownでの配置指定 (左寄せ/中央/右寄せ) の反映 */
    .prose th[align="center"], .prose td[align="center"] { text-align: center; }
    .prose th[align="right"], .prose td[align="right"] { text-align: right; }
    .prose th[align="left"], .prose td[align="left"] { text-align: left; }
    
    .prose tbody tr:nth-child(even) { background-color: #f8fafc; }
    .prose tbody tr:hover { background-color: #f1f5f9; }
    

🔍 問題 2: 目次コンポーネントで React key 重複エラー(key=""

発生していた現象

コンソールに以下のような React のエラーが出力されていました:

Encountered two children with the same key, "". Keys should be unique so that components maintain their identity across updates.
    at li
    at TableOfContents (src/components/TableOfContents.tsx)

原因

目次(TOC)抽出処理で、見出しテキストからアンカー ID を生成する際に以下のような正規表現を使っていました:

const id = text.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');

JavaScript の \w は半角英数字とアンダースコア([a-zA-Z0-9_])のみに一致するため、日本語の見出し文字がすべて除去されて id = ""(空文字)になっていました。 その結果、複数の日本語見出しがある記事で <li key={item.id}> がすべて key="" となり、React の key 重複エラーが発生していました。

また、rehype-slug が HTML 側の見出しに生成する ID(例: id="はじめに")とも不一致になり、目次をクリックしても該当位置へスクロールしない問題も起きていました。

解決策: github-slugger の導入

rehype-slug の内部でも使われている公式スラッグ生成ライブラリ github-slugger を導入します。

  1. github-slugger をインストール:

    pnpm add github-slugger
    
  2. src/lib/markdown.ts で ID 生成に利用:

    import GithubSlugger from 'github-slugger';
    
    export async function processMarkdownAndGenerateToc(markdownContent: string) {
      const toc: TocItem[] = [];
      const slugger = new GithubSlugger();
    
      const processor = remark().use(remarkParse).use(remarkGfm);
      const ast = processor.parse(markdownContent);
    
      visit(ast, 'heading', (node) => {
        const heading = node as { depth: number };
        if (heading.depth >= 2 && heading.depth <= 3) {
          const text = toString(node);
          // rehype-slug と全く同じアルゴリズムで日本語・重複対応IDを生成
          const id = slugger.slug(text);
    
          toc.push({ id, level: heading.depth, text });
        }
      });
      // ...
    }
    
  3. 目次コンポーネント(src/components/TableOfContents.tsx)の key を安全化:

    {toc.map((item, index) => (
      <li
        key={`${item.id || 'toc'}-${index}`}
        className={item.level === 3 ? 'ml-4' : ''}
      >
        <a href={`#${item.id}`} className="text-gray-600 hover:text-blue-600">
          {item.text}
        </a>
      </li>
    ))}
    

🔍 問題 3: コードブロック内のダブルクォートや記号が ' / " になる

発生していた現象

Markdown のコードブロック内に書いたコード中の '"`<> などの記号が、ブラウザ上で '"、```、< のまま文字化けして表示されていました。

原因: ヘッドレス WordPress の保存時サニタイズと二重エスケープ

  1. WordPress 側の挙動:
    WordPress のカスタムフィールド(ACF 等)に Markdown を保存する際、サニタイズフィルター(wp_kses やエディタ処理)によって記号が HTML エンティティ(' 等)に変換されて保存されます。
  2. remark の挙動:
    remark は Markdown 内の ' を「そのまま表示したい文字列」として解釈するため、HTML 変換時に ' とエスケープして出力します。その結果、ブラウザには文字通りの ' が表示されてしまいます。

解決策: entities ライブラリによる安全なデコード

  1. entities をインストール:

    pnpm add entities
    
  2. Markdown 処理(src/lib/markdown.ts)の前処理として多重エンコードを解除:

    import { decode } from 'entities';
    
    /**
     * WordPress等で二重・多重エンコードされたHTMLエンティティ(', "等)をデコード
     */
    function decodeHtmlEntities(content: string): string {
      if (!content) return '';
      let prev = content;
      let decoded = decode(content);
      let iterations = 0;
      while (decoded !== prev && iterations < 10) {
        prev = decoded;
        decoded = decode(decoded);
        iterations++;
      }
      return decoded;
    }
    
    export async function processMarkdownAndGenerateToc(markdownContent: string) {
      const cleanMarkdown = decodeHtmlEntities(markdownContent);
      // cleanMarkdown を remark パイプラインに渡す
      // ...
    }
    
  3. 記事一覧の抜粋(src/lib/utils.tsstripTags)でもデコードを通す:

    import { decode } from 'entities';
    
    export const stripTags = (html: string) => {
      if (!html) return '';
      const decoded = decode(html);
      return decoded
        .replace(/<[^>]*>/g, '')
        .replace(/[#*`]/g, '')
        .replace(/\s+/g, ' ')
        .trim();
    };
    

🔍 問題 4: コードブロックのシンタックスハイライト対応

要件

コードブロック(javascript ... や ```bash ... ````)を言語ごとにシンタックスハイライトし、クライアント側の JavaScript 実行コストをかけずにビルド時(SSG)に高速生成したい。

解決策: rehype-highlight + highlight.js の導入

  1. パッケージをインストール:

    pnpm add rehype-highlight highlight.js
    
  2. src/lib/markdown.tsrehypeHighlight を組み込む:

    import rehypeHighlight from 'rehype-highlight';
    
    const result = await remark()
      .use(remarkParse)
      .use(remarkGfm)
      .use(remarkRehype)
      .use(rehypeSlug)
      .use(rehypeHighlight) // ★ ビルド時に自動ハイライト
      .use(rehypeStringify)
      .process(cleanMarkdown);
    
  3. src/app/globals.css にテーマ CSS をインポートし、スタイルを調整:

    @import "tailwindcss";
    @import "highlight.js/styles/atom-one-dark.css"; /* お好みのテーマ */
    
    /* Code block styling */
    .prose pre {
      background-color: #282c34;
      color: #abb2bf;
      border-radius: 0.75rem;
      padding: 1.25rem 1.5rem;
      overflow-x: auto;
      font-size: 0.9rem;
      line-height: 1.6;
      margin: 1.5rem 0;
      border: 1px solid #3e4451;
    }
    
    .prose pre code {
      background-color: transparent !important;
      padding: 0 !important;
      border-radius: 0 !important;
      color: inherit !important;
      font-size: inherit !important;
      border: none !important;
    }
    
    .prose pre code::before,
    .prose pre code::after {
      content: "" !important;
    }
    
    /* インラインコード (`code`) の装飾 */
    .prose :not(pre) > code {
      background-color: #f1f5f9;
      color: #e11d48;
      padding: 0.2rem 0.45rem;
      border-radius: 0.375rem;
      font-size: 0.875em;
      font-weight: 500;
      border: 1px solid #e2e8f0;
    }
    
    .prose :not(pre) > code::before,
    .prose :not(pre) > code::after {
      content: "" !important;
    }
    

🎯 まとめ

発生していた問題 原因 解決策
表(Table)が崩れる remark-parse が CommonMark 標準のため GFM 非対応 remark-gfm の導入 + レスポンシブ CSS
目次の React key 重複エラー [^\w-] で日本語が消えて id="" になっていた github-slugger の導入 + key の安全化
コードのクォート文字化け WordPress のサニタイズによる HTML エンティティの二重エスケープ entities による Markdown 前処理デコード
シンタックスハイライト レンダリング時にハイライト処理が未適用 rehype-highlight による SSG ビルド時静的着色

ヘッドレス WordPress × Next.js(SSG)構成では、WordPress 側の仕様(サニタイズや独自フィルター)とフロントエンド側の Markdown パーサー(CommonMark / GFM / rehype)の仕様のギャップを理解してパイプラインを整えることが重要です。

同じような構成で Markdown の表示崩れに悩んでいる方の参考になれば幸いです!