← knym.net

WordPressをNext.js + MDXブログに移行した全手順

·11 min read
WordPressNext.jsMDX移行SEO

knym.net は長らく WordPress で運営していたが、Next.js + MDX に移行することにした。

理由は単純で、AI 時代にブログを書くなら git で管理・Vercel でデプロイ・MDX でコンポーネントも使える構成が記事を作りやすく自分のワークフローに合っていると感じたから。
あとwordpressはAIの進化に合わせてサイト攻撃が増えてきており、wordpress本体+pluginの更新を定期的にやらないとセキュリティが保てないのが大変というのも理由。 Next.jsで足りない機能は実装できるので、wordpressのプラグインを探す手間も省ける。

ただ既存の記事にはそれなりのアクセスがある。SEO を壊さずに移行するのが今回の最大の課題だった。

移行の全体フロー

WordPress 管理画面
  → WXR(XML)エクスポート
  → Node.js スクリプトで JSON に変換
  → 画像を public/img/ にダウンロード
  → HTML → MDX に変換
  → 301 リダイレクト設定を生成
  → next.config.ts に追加
  → pnpm build で確認

スクリプトは以下のリポジトリで公開している。ESM 形式の .mjs ファイル群で、それぞれ独立して実行できる。

knym/wp-to-nextjs

WordPress (WXR エクスポート) を Next.js + MDX に移行するスクリプト一式。記事変換・画像取得・リダイレクト生成に対応。

GitHub

Step 1: Google Search Console で移行対象の投稿を決める

まず Google Search Console からページ別クリック数を CSV でエクスポートした。
「検索パフォーマンス → ページ → クリック数降順」の順で確認し、クリック数5以上の記事を移行対象にした。

今回は20記事が対象。全部で200件以上の投稿があるなかで、実際にアクセスを生んでいる記事はこれだけだった。 価値のない投稿ということで、さっぱりした。

Step 2: WordPress から WXR をエクスポート

WordPress 管理画面の「ツール → エクスポート → 投稿」から XML(WXR 形式)をダウンロードして data-WP/ に保存した。

Step 3: parse-wxr.mjs で記事リストを生成

node parse-wxr.mjs ../data-WP/export.xml

xml2js で WXR を JSON に変換し、公開済みの投稿だけを抽出する。出力は posts-list.json(一覧確認用)と posts-full.json(本文入り、変換用)の2ファイル。

import { parseStringPromise } from 'xml2js';
 
const data = await parseStringPromise(xml, { explicitArray: true });
const posts = data.rss.channel[0].item
  .filter(item =>
    item['wp:post_type']?.[0] === 'post' &&
    item['wp:status']?.[0] === 'publish'
  )
  .map(item => ({
    slug: item['wp:post_name']?.[0],
    title: item.title?.[0],
    date: new Date(item.pubDate?.[0]).toISOString().split('T')[0],
    content: item['content:encoded']?.[0],
    // ...
  }));

GSC の CSV と posts-list.json を照らし合わせて、移行するスラッグを target-slugs.json に書き出した。

Step 4: 画像をダウンロード

SITE_URL=https://yoursite.com node download-images.mjs

記事の HTML に含まれる {SITE_URL}/wp-content/uploads/... を正規表現で抽出し、node-fetch でダウンロードして public/img/ に保存する。WordPress の /YYYY/MM/filename.jpg パスはハイフン結合してフラットなファイル名に変換する。

ダウンロード先と元 URL の対応表を image-map.json として出力し、次の変換ステップで使う。

// --- CONFIG ---
const SITE_URL = process.env.SITE_URL || 'https://example.com';
const IMG_DIR = process.env.IMG_DIR || '../your-nextjs-app/public/img/';
 
// WordPress 画像 URL → ローカルファイル名に変換
// /wp-content/uploads/2022/06/IMG_9393.jpg → 2022-06-IMG_9393.jpg
function urlToLocalName(imageUrl) {
  const parts = new URL(imageUrl).pathname
    .replace('/wp-content/uploads/', '')
    .split('/');
  return parts.join('-');
}

80枚の画像を問題なく取得できた。

Step 5: HTML → MDX に変換

node convert-to-mdx.mjs

ここが本題。turndown + turndown-plugin-gfm を使って WordPress の HTML を Markdown に変換する。

import TurndownService from 'turndown';
import { gfm } from 'turndown-plugin-gfm';
 
const td = new TurndownService({
  headingStyle: 'atx',
  codeBlockStyle: 'fenced',
});
td.use(gfm);
 
// 画像 src を WordPress URL → ローカルパスに差し替え
td.addRule('local-image', {
  filter: 'img',
  replacement: (_, node) => {
    const src = node.getAttribute('src') || '';
    const localSrc = IMAGE_MAP[src] || src;
    const alt = node.getAttribute('alt') || '';
    return `![${alt}](${localSrc})`;
  },
});

Gutenberg のブロックコメント(<!-- wp:paragraph -->)と WordPress のショートコード([caption] 等)は正規表現で除去してから渡す。

frontmatter はスラッグ・タイトル・日付・タグ・description・アイキャッチ画像パスを自動生成した。

---
title: "ベアフットシューズを試し履きできる店舗を都内で探し歩いた"
date: "2022-06-26"
tags: ["日常の出来事など"]
description: "都内でベアフットシューズを試し履きできる店舗を実際に巡ったレポート。"
image: "/img/2022/06-IMG_9393-1-1-scaled.jpg"
---

変換後は各ファイルを目視で確認した。自動変換の限界として、コードブロックの言語指定が抜けるケースがあった(WordPress の旧エディタはコード言語を明示しないため)。

Step 6: 301 リダイレクトを生成

node generate-redirects.mjs

WordPress の URL 構造は knym.net/{slug}/(投稿名のみ)だった。Next.js は /blog/{slug} にしていたので、旧 URL からのリダイレクトが必要。

redirects.push({
  source: wpPath,           // /tokyo-barefoot-shoes-shop
  destination: `/blog/${post.slug}`,
  permanent: true,
});

移行対象外の記事(クリック数が少ないもの)はトップページ / へリダイレクト。WordPress の管理 URL(/wp-admin/feed/category/*)もあわせてトップへ向けた。全部で427件のリダイレクト設定が生成された。

Step 7: next.config.ts に追加

生成した redirects-output.jsonredirects.json としてプロジェクトに配置し、next.config.ts で読み込む。

import { createRequire } from "module";
const require = createRequire(import.meta.url);
const redirectsData = require("./redirects.json") as {
  source: string;
  destination: string;
  permanent: boolean;
}[];
 
const nextConfig: NextConfig = {
  turbopack: { root: path.resolve(__dirname) },
  serverExternalPackages: ["shiki"],
  async redirects() {
    return redirectsData;
  },
};

pnpm build が通ることを確認してから curl -Is でリダイレクトを検証した。

curl -Is http://localhost:3001/tokyo-barefoot-shoes-shop | head -3
# HTTP/1.1 308 Permanent Redirect
# location: /blog/tokyo-barefoot-shoes-shop

308 は Next.js の permanent: true 時の挙動で、検索エンジンは 301 と同等に扱う。

ハマったポイント

移行した記事が 404 になる(楽天・Amazon ウィジェットの HTML が原因)

移行後に一部の記事が 404 になるケースが複数あった。
原因は WordPress 時代に貼った楽天・Amazon アフィリエイトのウィジェット HTML が MDX v2 のコンパイルを壊していたこと。

MDX v2 以降、本文中の HTML は JSX として解釈される。JSX では style 属性に CSS 文字列を使えない(オブジェクトが必要)ため、こういった HTML があるとコンパイルエラーになる。

<!-- これが MDX v2 でエラーになる -->
<table border="0" cellpadding="0" style="width:500px">
  <tr><td style="font-size:12px">...</td></tr>
</table>

エラーは compileMDX が投げるが、Next.js App Router では記事ページ全体が 404 になるだけでターミナルにエラーが出ないため、気づきにくい。

問題のある記事を一括で見つける方法:

# style=" を含む記事をスキャン(コードブロック内は後で目視確認)
find content/posts -name "*.mdx" | while read mdx; do
  grep -qE 'style="' "$mdx" && echo "$mdx"
done

コードブロック内に style=" があるだけなら問題ない。HTML タグの属性として直接書かれているものだけが対象。

実際に 404 が起きているか確認するには、dev server を起動してエンドポイントを叩く方が確実:

find content/posts -name "*.mdx" | while read mdx; do
  slug=$(basename "$mdx" .mdx)
  code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:PORT/blog/${slug}")
  [ "$code" = "404" ] && echo "404: $slug"
done

修正方針:

楽天・Amazon ウィジェットの HTML ブロックを削除して、シンプルなテキストリンクか後述の AmazonCard コンポーネントに差し替えるのが一番早い。ウィジェットの HTML を MDX 対応の JSX に書き直す方法もあるが、style を JS オブジェクトに変換・classclassName 変換など手間がかかるわりに見た目がよくないのでおすすめしない。

turndown の変換精度

WordPress のクラシックエディタ記事は比較的きれいに変換できた。Gutenberg(ブロックエディタ)の記事はブロックコメントが混入していたが、replace(/<!--\s*wp:[^>]*-->/g, '') で処理できた。

記事ファイルの整理

MDX ファイルは content/posts/YYYY/slug.mdx の年別ディレクトリに整理した。lib/mdx.tsgetAllPosts()fs.readdirSync(dir, { recursive: true }) でサブディレクトリを再帰的にスキャンするよう変更した。

画像も同様に public/img/YYYY/ で年別管理にした。

lib/mdx.ts のサブディレクトリ対応

function getMdxFiles(): string[] {
  return (fs.readdirSync(POSTS_DIR, { recursive: true }) as string[])
    .filter((f) => f.endsWith(".mdx"));
}
 
function findMdxPath(slug: string): string {
  const found = getMdxFiles().find((f) => path.basename(f, ".mdx") === slug);
  if (!found) throw new Error(`Post not found: ${slug}`);
  return path.join(POSTS_DIR, found);
}

まとめ

スクリプトを書いて実行するだけで20記事・80画像の移行が1時間ほどで完了した。手作業でやっていたら数日かかる作業量だと思う。

移行先の Next.js サイトは pnpm build で静的ファイルとして出力されるので、Vercel へのデプロイも vercel コマンド1発で済む。DNS の切り替えタイミングさえ慎重にやれば、SEO へのダメージは最小限で移行できるはず。

変換スクリプト一式は上記リポジトリで公開しました。
claude codeで実装してと言えば作ってもらえる内容ですが、参考になれば。

knym/wp-to-nextjs

WordPress (WXR エクスポート) を Next.js + MDX に移行するスクリプト一式。記事変換・画像取得・リダイレクト生成に対応。

GitHub

記事全体をmdxでローカル管理できると一括更新やAIでのチェック、更新がしやすくて移行して良かったです😃