1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WordPress記事1000件をNext.js MDXに移行して表示速度を爆速にした話

1
Posted at

はじめに

Futuristic Imagination LLC 代表の佐藤です。弊社ではAIオウンドメディア18サイトを一人で自動運用し、9言語展開で年間6,570記事を自動生成しています。そのバックボーンを支えているのが、Next.js + Gemini API + Vercel Cron を組み合わせた、まさしく「補給不要の自販機型」自動化システムです。

しかし、全てのプロジェクトが最初からNext.jsだったわけではありません。今回は、過去にWordPressで運用していた大量の記事(なんと1000件以上!)を、Next.jsのMDX形式に移行し、表示速度を劇的に改善した実体験についてお話します。

WordPressの速度や運用コストに悩んでいる方、Next.jsへの移行を検討している方、そして何より「この作業、自動化できないかな?」と考えている方にとって、この記事が少しでもお役に立てれば幸いです。

この記事でわかること

  • WordPressからNext.js MDXへの移行スクリプトの具体的な設計思想
  • WordPressのエクスポートデータ(WXR)から必要な情報を抽出する方法
  • HTMLからMarkdown(MDX)への変換における課題とその解決策
  • 実際に動作するTypeScriptでの移行スクリプトのコード例
  • 移行を通じて得られたNext.jsのパフォーマンスメリットと運用ノウハウ

😱 WordPressの限界とNext.js MDXへの移行を決めた理由

「なぜ、WordPressから移行する必要があったのか?」

これは多くの人が疑問に思うことでしょう。WordPressは優れたCMSであり、多くのサイトで利用されています。しかし、弊社が追求する「極限まで自動化された高生産性エコシステム」の実現において、いくつかの限界を感じていました。

  1. 表示速度とSEOパフォーマンスの限界:
    WordPressはPHPで動くため、どうしてもサーバーサイドの処理オーバーヘッドが大きくなります。キャッシュプラグインである程度は改善できますが、Next.jsのISR(Incremental Static Regeneration)やSSG(Static Site Generation)に比べると、体感速度に大きな差がありました。特に、SEOを重視するオウンドメディアにおいて、Core Web Vitalsの改善は喫緊の課題だったのです。
  2. 運用・保守コストの増大:
    プラグインのアップデート、PHPのバージョンアップ、セキュリティパッチ適用…これらのWordPress固有の運用タスクは、自動化を追求する弊社にとって大きな負担でした。一つでも脆弱性が見つかれば即座に対応しなければなりません。「この作業はもう不要かな?」と常に考えている私としては、自動化できない手作業は極力排除したかったのです。
  3. 開発の柔軟性:
    WordPressはテーマやプラグインで多くのカスタマイズが可能ですが、JavaScriptエコシステムに慣れ親しんだエンジニアとして、Next.jsとTypeScriptでの開発の柔軟性や生産性の高さは捨てがたいものでした。AIを活用した自動コンテンツ生成パイプラインを構築する際も、Next.jsのモダンな開発環境の方が圧倒的に効率的でした。

これらの課題を解決するため、Next.jsのMDX(Markdown with JSX)への移行を決断しました。MDXはMarkdownのシンプルさにJSXの表現力を組み合わせたもので、静的サイトジェネレーションと組み合わせることで、高速な表示と高い開発体験の両立が可能です。


🛠 移行スクリプトの全体像と技術選定

WordPressからNext.js MDXへの移行は、手作業では到底不可能な量です。そこで、TypeScriptで移行スクリプトを自作することにしました。

移行フローの概要

  1. WordPressエクスポート: WordPressの管理画面から全記事をXML形式(WXRファイル)でエクスポートします。
  2. WXRファイルのパース: エクスポートしたXMLを読み込み、記事データ(タイトル、スラッグ、本文、カテゴリなど)を抽出します。
  3. HTML to MDX変換: 抽出した記事本文(HTML)をMDX形式に変換します。これが最も複雑な工程です。
  4. ファイル出力: 変換したMDXデータをNext.jsのブログディレクトリ構成に合わせてファイルとして保存します。

技術スタック

  • Node.js / TypeScript: スクリプト実行環境。型安全性を確保するためTypeScriptを採用。
  • xml2js: WXRファイル(XML)をJavaScriptオブジェクトにパースするため。
  • turndown / turndown-plugin-gfm: HTMLからMarkdownへの変換ライブラリ。GitHub Flavored Markdownをサポート。
  • fs-extra: ファイルシステムの操作(ファイルの読み書き、ディレクトリ作成など)を便利に行うため。

📝 WXRファイルのパースと記事データの抽出

まずは、WordPressからエクスポートしたWXRファイルを解析し、必要な記事データを抽出する部分です。

WordPressのエクスポートファイルはXML形式なので、xml2jsを使ってパースします。

npm install xml2js fs-extra --save-dev
npm install @types/xml2js --save-dev
// src/scripts/migrate-wordpress.ts
import * as fs from 'fs-extra';
import { parseStringPromise } from 'xml2js';
import TurndownService from 'turndown';
import { gfm } from 'turndown-plugin-gfm';

interface WordPressPost {
  title: string;
  slug: string;
  pubDate: string;
  content: string; // HTML content
  category: string[];
  status: string;
}

async function parseWxrFile(filePath: string): Promise<WordPressPost[]> {
  const xml = await fs.readFile(filePath, 'utf8');
  const result = await parseStringPromise(xml);

  const posts: WordPressPost[] = [];
  const items = result.rss.channel[0].item;

  for (const item of items) {
    // 投稿タイプが 'post' かつ公開済みのみを対象とする
    const postType = item['wp:post_type'] ? item['wp:post_type'][0] : null;
    const postStatus = item['wp:status'] ? item['wp:status'][0] : null;

    if (postType === 'post' && postStatus === 'publish') {
      const title = item.title ? item.title[0] : 'No Title';
      const slug = item['wp:post_name'] ? item['wp:post_name'][0] : title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-*|-*$/g, '');
      const pubDate = item.pubDate ? item.pubDate[0] : new Date().toISOString();
      const content = item['content:encoded'] ? item['content:encoded'][0] : '';
      
      const categories: string[] = [];
      if (item.category) {
        item.category.forEach((cat: any) => {
          if (cat.$.domain === 'category') {
            categories.push(cat._);
          }
        });
      }

      posts.push({
        title,
        slug,
        pubDate,
        content,
        category: categories,
        status: postStatus,
      });
    }
  }

  console.log(`WXRファイルから ${posts.length} 件の記事を抽出しました。`);
  return posts;
}

このparseWxrFile関数では、XMLをパースした後、rss.channel[0].itemの中から投稿データを取り出しています。wp:post_typepostwp:statuspublishのものだけを対象にすることで、固定ページや下書き記事を除外できます。

また、wp:post_nameからスラッグを、content:encodedから記事本文(HTML)を抽出しています。カテゴリー情報も適切に取得するようにしています。


↔ HTMLからMDXへの変換:turndownの活用

次に、抽出した記事本文(HTML)をMDX形式に変換する部分です。ここが移行作業の肝となります。

turndownライブラリはHTMLをMarkdownに変換する強力なツールです。GitHub Flavored Markdown (GFM) のプラグインも用意されているため、コードブロックなども比較的きれいに変換できます。

// src/scripts/migrate-wordpress.ts (parseWxrFile関数の続きに追記)

// Turndownサービスの設定
const turndownService = new TurndownService({
  headingStyle: 'atx',
  codeBlockStyle: 'fenced',
  preformattedCode: true,
});
turndownService.use(gfm);

// WordPress固有のショートコードやHTMLをカスタムルールで処理する
// 例: [caption]ショートコードの除去
turndownService.addRule('captionShortcode', {
  filter: (node: any) => {
    return node.nodeName === 'DIV' && node.className.includes('wp-caption');
  },
  replacement: (content: string, node: any) => {
    // caption内の画像などはそのまま残す場合、contentを返す
    // 完全除去なら空文字を返す
    return content; 
  }
});

// もし<pre>タグ内にSyntaxHighlighterなどのコードがある場合、````ts ```のような形式に変換するルール
turndownService.addRule('preCodeBlocks', {
  filter: (node: any) => {
    return node.nodeName === 'PRE' && node.textContent.trim().length > 0;
  },
  replacement: (content: string, node: any) => {
    // 言語指定がもしあればここで行う(WordPressのclassNameから推測するなど)
    const lang = 'typescript'; // デフォルトとしてtsとするか、正規表現でclassから抽出
    return `\`\`\`${lang}\n${node.textContent.trim()}\n\`\`\`\n`;
  }
});

// HTMLからMDXへの変換関数
function convertHtmlToMdx(htmlContent: string): string {
  // WordPress特有のHTMLコメント(<!-- wp:paragraph -->など)を除去
  let cleanedHtml = htmlContent.replace(/<!-- wp:.*? -->/g, '');
  
  // WordPressの画像ブロックの<figure>タグ対応
  // <figure class="wp-block-image"><img ... /></figure> -> ![](...)
  cleanedHtml = cleanedHtml.replace(
    /<figure[^>]*class="wp-block-image"[^>]*>.*?<img([^>]*)src="([^"]*)"([^>]*)>.*?<\/figure>/g,
    (match, p1, src, p2) => {
      // alt属性があれば取得
      const altMatch = p1.match(/alt="([^"]*)"/);
      const alt = altMatch ? altMatch[1] : '';
      return `![${alt}](${src})`;
    }
  );

  // TurndownでMarkdownに変換
  let markdown = turndownService.turndown(cleanedHtml);

  // MDX特有の調整(例: JSXが埋め込まれている場合の対処など)
  // コードブロック内のJSX構文がMarkdownと衝突しないようにエスケープ処理を検討する
  // 例えば、`{}`がそのままMarkdownの強調と解釈されないように、MDXのRemarkプラグインで対応することも可能
  
  return markdown;
}

この部分では、turndownServiceのインスタンスを生成し、gfmプラグインを適用しています。

カスタムルールの重要性

WordPressの出力するHTMLには、ショートコードや特定のクラス名が付与された<figure>タグなど、Markdown変換器が標準では対応しきれない要素が多く含まれます。これらを適切に処理するために、addRuleを使ってカスタムルールを定義することが非常に重要です。

例えば、[caption]ショートコードはHTML上では<div class="wp-caption">のような形で出力されることが多いですが、これをMDXには不要と判断して除去するルールや、preタグ内のコードブロックをMDXのフェンスコードブロック(```)形式に変換するルールなどを追加しています。

弊社では、ブログ記事中にYouTubeの埋め込みや特定のCTAコンポーネントをJSXとして挿入するため、それらに対応するカスタムルールも追加で実装しています。turndownは非常に柔軟なので、あらゆるケースに対応できるでしょう。


💾 MDXファイルの出力とフロントマターの生成

最後に、変換したMDXコンテンツをファイルとして保存します。Next.jsのブログでは、通常、MDXファイルにフロントマター(YAML形式のメタデータ)を含めることが多いので、これも自動で生成します。

// src/scripts/migrate-wordpress.ts (main関数の定義と実行)

async function main() {
  const wxrFilePath = './wordpress.xml'; // WordPressエクスポートXMLのパス
  const outputDir = './src/content/posts'; // Next.jsプロジェクトのMDX出力先

  await fs.ensureDir(outputDir); // 出力ディレクトリが存在しない場合は作成

  const posts = await parseWxrFile(wxrFilePath);

  for (const post of posts) {
    if (!post.slug) {
        console.warn(`Warning: スラッグが設定されていない記事をスキップします: ${post.title}`);
        continue;
    }
    const mdxFileName = `${post.slug}.mdx`;
    const mdxFilePath = `${outputDir}/${mdxFileName}`;

    const mdxContent = convertHtmlToMdx(post.content);

    // フロントマターの生成
    const frontMatter = `---
title: "${post.title.replace(/"/g, '\\"')}"
date: "${new Date(post.pubDate).toISOString()}"
category: [${post.category.map(cat => `"${cat}"`).join(', ')}]
slug: "${post.slug}"
status: "${post.status}"
---

`;

    await fs.writeFile(mdxFilePath, frontMatter + mdxContent, 'utf8');
    console.log(`Generated: ${mdxFilePath}`);
  }

  console.log('すべてのWordPress記事の移行が完了しました!');
}

main().catch(console.error);

フロントマターの設計

MDXファイルは、冒頭にYAML形式のメタデータ(フロントマター)を含めることができます。これによって、タイトル、公開日、カテゴリー、スラッグなどの記事情報を簡単に管理できます。

フロントマターのキー名は、Next.jsで記事一覧を生成したり、個別の記事ページで情報を表示したりする際に利用します。日付はISO形式で統一し、カテゴリーは配列として格納しています。

実行方法

このスクリプトをsrc/scripts/migrate-wordpress.tsとして保存し、tsconfig.jsonで適切な設定("module": "commonjs""target": "es2017"など)を行ってから、以下のように実行します。

npx ts-node src/scripts/migrate-wordpress.ts

これにより、src/content/postsディレクトリ配下に、WordPressの全記事がMDXファイルとして生成されます。


✅ Next.jsでのMDXの活用とパフォーマンス改善

記事がMDXとして生成されたら、Next.js側でこれらのMDXファイルを読み込み、表示する設定を行います。

next-mdx-remoteを使ったMDXのレンダリング

弊社では、Next.jsでMDXをレンダリングするためにnext-mdx-remoteを使用しています。これにより、サーバーサイドでMDXをHTMLに変換し、フロントエンドでSSR/SSGされたコンテンツを高速に表示できます。

npm install next-mdx-remote --save
// pages/posts/[slug].tsx (例: 記事詳細ページ)
import { GetStaticProps, GetStaticPaths } from 'next';
import { serialize } from 'next-mdx-remote/serialize';
import { MDXRemote, MDXRemoteSerializeResult } from 'next-mdx-remote';
import * as fs from 'fs-extra';
import path from 'path';

interface PostPageProps {
  source: MDXRemoteSerializeResult;
  frontMatter: {
    title: string;
    date: string;
    // 他のフロントマター
  };
}

const POSTS_DIRECTORY = path.join(process.cwd(), 'src/content/posts');

export default function PostPage({ source, frontMatter }: PostPageProps) {
  return (
    <article>
      <h1>{frontMatter.title}</h1>
      <p>Published: {new Date(frontMatter.date).toLocaleDateString()}</p>
      <MDXRemote {...source} />
    </article>
  );
}

export const getStaticPaths: GetStaticPaths = async () => {
  const filenames = await fs.readdir(POSTS_DIRECTORY);
  const paths = filenames
    .filter(filename => filename.endsWith('.mdx'))
    .map(filename => ({
      params: { slug: filename.replace(/\.mdx$/, '') },
    }));

  return {
    paths,
    fallback: false, // 存在しないパスは404
  };
};

export const getStaticProps: GetStaticProps<PostPageProps> = async ({ params }) => {
  const { slug } = params as { slug: string };
  const mdxPath = path.join(POSTS_DIRECTORY, `${slug}.mdx`);
  const mdxSource = await fs.readFile(mdxPath, 'utf8');

  // MDXをパースし、フロントマターとコンテンツを分離
  const { compiledSource, frontmatter } = await serialize(mdxSource, {
    parseFrontmatter: true,
  });

  return {
    props: {
      source: compiledSource as MDXRemoteSerializeResult,
      frontMatter: frontmatter as any,
    },
  };
};

この例では、getStaticPathsで全てのMDXファイルのスラッグを抽出し、getStaticPropsで個別のMDXファイルを読み込んでnext-mdx-remoteserialize関数でパースしています。これにより、ビルド時に静的なHTMLファイルを生成(SSG)できるため、表示速度は劇的に向上します。

実際に移行後、WordPress時代と比較して、ページの読み込み速度は平均で数秒から数百ミリ秒まで短縮されました。これはSEOパフォーマンスにも直結し、検索順位の向上にも寄与しています。

AIと組み合わせた自動リライト

弊社では、このMDXベースのNext.js環境をさらに発展させ、Google Search Console APIとLLM(Gemini API)を連携させることで、低順位の記事を自動検出し、最新のトレンドに合わせて自動リライト・再インデックス送信(IndexNow)まで完結させるシステムを構築しています。

「〇〇って切り替えたから、この作業はもう不要かな?」という私の問いかけは、このような自動化システムによって日々現実のものとなっています。人間は「コアな創造的思考」に集中し、ルーティンワークはAIとシステムに任せる。これがFuturistic Imagination LLCの目指す未来です。


💡 移行を終えて得られた知見と今後の展望

WordPressからNext.js MDXへの1000件規模の移行は、労力はかかりましたが、それに見合う大きなメリットがありました。

メリット

  • 爆速の表示速度とSEO向上: SSG/ISRによる高速レンダリングは、ユーザー体験と検索エンジンからの評価を劇的に改善しました。
  • 運用コストの削減: WordPress特有のプラグイン管理やアップデート作業から解放され、メンテナンスコストが大幅に削減されました。
  • 開発の自由度向上: TypeScriptとNext.jsのモダンな開発環境により、AIとの連携や新しい機能の実装が圧倒的にスムーズになりました。
  • セキュリティの強化: 静的サイトであるため、WordPressの脆弱性に起因するセキュリティリスクを大幅に低減できました。

注意点と工夫

  • 画像パスの修正: WordPressの記事内の画像は相対パスで埋め込まれていることが多いため、MDX移行後に正しい画像パス(CDNなど)に書き換える処理が必要です。
  • 内部リンクの修正: WordPressの内部リンク構造とNext.jsのルーティングが異なる場合、リンク切れが発生しないように修正スクリプトを追加する必要があります。
  • カスタムブロックの対応: WordPressのGutenbergブロックエディタで作成された複雑なレイアウトは、Markdownに変換しにくい場合があります。これらのブロックをMDXコンポーネントとして再構築するか、シンプルに変換するルールを考える必要があります。

まとめ

本記事では、WordPressに蓄積された1000件もの記事をNext.js MDXに移行するための具体的なスクリプトと、その過程で得られた知見を共有しました。

「なんかこれって〜じゃない?」「〜ないかな?」という探求心から始まったこのプロジェクトは、結果として弊社のAIオウンドメディアのパフォーマンスを飛躍的に向上させ、より高度な自動化システム構築への足がかりとなりました。

弊社自身がヘビーユーザーとして使いこなしているこの技術スタックと自動化のノウハウは、MediaForge AIというB2B受託サービスとしてお客様にも提供しています。WordPressの重さに辟易している方、Next.jsへの移行を考えている方、AIを活用したメディア運用に興味がある方は、ぜひ一度ご相談ください。

今回紹介したようなシステムの構築代行も行っています → https://www.futuristicimagination.co.jp/service/

Futuristic Imagination LLCは、これからも最先端の技術を駆使し、「極限まで自動化された高生産性エコシステム」の構築を目指し続けます。

転職・副業・キャリアに関するShorts動画を毎日配信中 → https://www.youtube.com/channel/UCFobIbWz1KDKaIdDqXpTPAA

今後もQiitaや各SNSで、私たちの実践的な取り組みやノウハウを発信していきますので、ぜひフォローをお願いします!LGTM、お待ちしています!

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?