フロントエンド

旧CMSの記事URLを落とさずにNext.jsへ移行する|sitemapから旧URLを抽出し、proxyで301する設計と正規化の罠

旧CMSで運用していたサイトをNext.jsに置き換えると、旧サイトの記事URLはそのままでは404になります。検索結果や外部サイトからのリンクを失わないためには、旧記事のURLを移転先へ301で転送する必要があります。この記事では、旧URLを落とさずに移行する設計と、実装で踏みやすい2つの罠をまとめます。

この記事の検証結果はNext.js 16.4.0と16.3.8(2026年10月時点)で確認したものです。ドメインやパスはすべて架空の値です。proxyの置き場所については、前回の記事「【Next.js 16】middlewareが開発環境だけ動かない原因」を参照してください。

スポンサーリンク

結論:旧URLの対応表を先に作り、一致したものだけを301する

先に結論です。

  1. 切り替えの前に、旧サイトのsitemapから記事URLを全件抽出して、対応表としてリポジトリに置きます。
  2. proxyで、対応表に一致したパスだけを、sitemapに載っていたとおりのURLへ301で転送します。新しいサイトのページには触れません。
  3. 末尾スラッシュの自動リダイレクトがproxyより先に動くので、skipTrailingSlashRedirectで止め、スラッシュの扱いをproxyで引き受けます。
  4. 日本語を含むURLは、デコードしてNFCに正規化してから比較します。

移行設計の全体像

架空の構成で考えます。

切り替え前 切り替え後
www.example.com 旧CMS(トップ・会社情報・記事) 新しいNext.jsのサイト
news.example.com — 旧CMSの記事をそのまま公開

切り替え後にwww.example.com/old-article-001/へアクセスが来たら、news.example.com/old-article-001/へパスを保ったまま 301で転送します。

ポイントは、「旧記事かどうか」をパスの形から推測しないことです。旧サイトの記事URLは、/2026/04/hello/のような日付付きもあれば、/ブログ/のような日本語のスラッグもあり、規則で書き切れないことがよくあります。前方一致や正規表現で転送すると、新しいサイトのページまで巻き込むおそれがあります。そこで、旧記事のURLを完全一致の対応表として持ちます。

手順1:sitemapから旧URLの対応表を作る

多くのCMSは、記事のURLを並べたsitemapを出力しています。切り替えるとwww.example.comのsitemapは新しいサイトのものに置き換わるので、切り替えの前に保存しておきます。

sitemapが、ほかのsitemapのURLを並べたインデックス形式(WordPressのwp-sitemap.xmlなど)になっている場合は、下のスクリプトに渡すと子のsitemapのURLをパスとして拾ってしまいます。子のsitemapもすべて保存し、それぞれをスクリプトに通してまとめてください。

保存したsitemapから、パスの対応表を作るスクリプトの例です。

// extract.mjs
// 使い方: node extract.mjs ./old-sitemap.xml > legacy-paths.json
import { readFile } from "node:fs/promises";

const xml = await readFile(process.argv[2], "utf8");
const map = {};

for (const [, loc] of xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)) {
  const url = new URL(loc.replace(/&amp;/g, "&"));
  let key = decodeURIComponent(url.pathname).normalize("NFC");
  if (!key.endsWith("/")) key += "/";
  if (key === "/") continue; // トップページは除外
  map[key] = url.pathname; // 値は sitemap に載っていたエンコードのまま
}

const sorted = Object.fromEntries(Object.entries(map).sort(([a], [b]) => a.localeCompare(b)));
console.log(JSON.stringify(sorted, null, 2));
console.error(`${Object.keys(sorted).length} paths`);

出力はこのような形になります。

{
  "/old-article-001/": "/old-article-001/",
  "/ブログ/": "/%E3%83%96%E3%83%AD%E3%82%B0/"
}

キーは比較に使う形で、デコード済み・NFC・末尾スラッシュ付きにそろえます。proxyの側も同じ形にそろえてから比べれば、表記の揺れで取りこぼしません。

値は転送先に使うパスで、sitemapに載っていたエンコードのまま残します。旧CMSが実際に出していたURLへそのまま転送できるので、旧CMSがどんな表記でパスを持っていても転送先で404になりません。デコードしたパスをエンコードし直すと、%3F(?)や%23(#)を含むパスが元に戻らず壊れることがありますが、この方法ならその心配もありません。

トップページ(/)は除外しておきます。トップは新しいサイトのトップとして使うからです。

手順2:proxyで一致したパスだけを301する

// src/proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import legacyPaths from "./data/legacy-paths.json";

const LEGACY = new Map<string, string>(Object.entries(legacyPaths));
const NEWS_ORIGIN = "https://news.example.com";

function normalizePath(raw: string): string | null {
  let path: string;
  try {
    path = decodeURIComponent(raw);
  } catch {
    return null; // 不正なエンコードは転送しない
  }
  path = path.normalize("NFC");
  return path.endsWith("/") ? path : `${path}/`;
}

export function proxy(request: NextRequest) {
  const { pathname, search } = request.nextUrl;

  // 旧記事だけを、sitemap に載っていたパスへ 301
  const key = normalizePath(pathname);
  const target = key ? LEGACY.get(key) : undefined;
  if (target) {
    return NextResponse.redirect(new URL(target + search, NEWS_ORIGIN), 301);
  }

  // 新しいサイトのページは、末尾スラッシュを外す(後述)
  if (pathname !== "/" && pathname.endsWith("/")) {
    const url = new URL(request.url);
    url.pathname = pathname.replace(/\/+$/, "");
    return NextResponse.redirect(url, 308);
  }
}

export const config = {
  matcher: ["/((?!_next/|favicon.ico).*)"],
};

届いたパスを正規化してキーにし、対応表の値(sitemapに載っていたパス)を転送先にします。こうすると、どんな表記で来ても転送先のURLは、旧CMSが出していた1つにそろいます。クエリ文字列(?以降)はそのまま引き継ぎます。

スポンサーリンク

罠1:末尾スラッシュの自動リダイレクトが先に割り込む

Next.jsは既定で、URLの末尾スラッシュをtrailingSlashの設定に合わせるリダイレクトを行います。既定のtrailingSlash: falseでは、/old-article-001/へのアクセスはproxyより先に /old-article-001へ308で転送されます。

検証すると、skipTrailingSlashRedirectを付けない場合は次のようになりました。

/old-article-001/ → 308 /old-article-001(Next.js の自動リダイレクト)
/old-article-001  → 301 https://news.example.com/old-article-001/(proxy)

転送はされますが、2回の転送になります。Google検索セントラルのサイト移転ガイドでも、リダイレクトのチェーンは避け、最終的な転送先へ直接リダイレクトするよう勧めています。そこでnext.config.tsに次を追加します。

const nextConfig = {
  skipTrailingSlashRedirect: true,
};

これで/old-article-001/も1回の301で転送されるようになりました。

ただし、このオプションには副作用があります。公式ドキュメントにあるとおり、/aboutと/about/の両方が200を返すようになり、検索エンジンから見ると同じページのURLが2つになります。上のproxyの後半で、新しいサイトのページの末尾スラッシュを外しているのはこのためです。

もう1つ注意点があります。検証では、request.nextUrl.clone()のpathnameから末尾スラッシュを外して転送すると、転送先にスラッシュが付いたままになり、同じURLへの転送を繰り返しました。new URL(request.url)を使えば、意図どおり外れます。

罠2:日本語URLは、エンコードとNFC/NFDの違いで一致しない

proxyで受け取るrequest.nextUrl.pathnameは、パーセントエンコードされたままです。

/ブログ/ へのアクセス → pathname は "/%E3%83%96%E3%83%AD%E3%82%B0/"

デコードせずに対応表と比べると、日本語のURLは1件も一致しません。

さらに、「ブ」のように濁点を含む文字は、Unicodeでは2通りの表し方があります。

形式 「ブ」の表し方 エンコード後
NFC 1文字(ブ) %E3%83%96
NFD 2文字(フ + 濁点) %E3%83%95%E3%82%99

見た目は同じでも、バイト列が違うので一致しません。macOSのファイル名からコピーした文字列などではNFDが混ざることがあります。検証でも、NFCに正規化しない場合はNFDで来たアクセスが404になり、正規化すると転送されました。

比較の前に、デコード → NFCに正規化 → 末尾スラッシュをそろえる。対応表のキーを作るときと同じ処理を通すのが確実です。

確認方法:対応表の全件にcurlする

対応表のすべてのURLについて、301であることと、転送先が対応表の値と一致することを確かめます。

node -e 'for (const [k, v] of Object.entries(require("./src/data/legacy-paths.json"))) console.log(encodeURI(k), v)' |
while read -r p v; do
  got="$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "https://www.example.com$p")"
  [ "$got" = "301 https://news.example.com$v" ] || echo "NG $p → $got"
done

何も出力されなければ、全件が正しい転送先へ301で転送されています。あわせて、新しいサイトのページ(トップや会社情報)が転送されていないことも確認してください。

チェックリスト

  • 切り替えの前に、旧サイトのsitemapを保存したか
  • 対応表のキーは「デコード済み・NFC・末尾スラッシュ付き」に、値はsitemapのパスのままにしたか
  • sitemapがインデックス形式なら、子のsitemapもすべて通したか
  • トップページを対応表から外したか
  • 前方一致や正規表現ではなく、完全一致で判定しているか
  • skipTrailingSlashRedirectを付け、新しいページの末尾スラッシュをproxyで処理しているか
  • 対応表の全件で、301と転送先を確認したか

よくある質問

Q. 302や308ではだめですか?

A. 302は一時的な転送なので、恒久的な移転には向きません。308も恒久的な転送で、メソッドを保つ点が異なります。記事ページへのアクセスはほぼGETなので、ここでは広く使われている301にしています。

Q. 対応表はいつ消してよいですか?

A. Google検索セントラルのサイト移転ガイドでは、リダイレクトはできるだけ長く(一般的には1年以上)保持するよう案内されています。外部サイトのリンクや古いブックマークからのアクセスも長く残るので、急いで消す必要はありません。

Q. sitemapに載っていない記事はどうなりますか?

A. 転送されません。非公開の記事や、sitemapから除外していたページがないか、旧CMSの管理画面の記事数と対応表の件数を比べておくと安心です。

まとめ

  • 旧記事のURLは、切り替えの前にsitemapから全件抽出して対応表にする
  • proxyでは、対応表に完全一致したパスだけを、sitemapに載っていたパスへ301する
  • 末尾スラッシュの自動リダイレクトが先に動くのでskipTrailingSlashRedirectで止め、新しいページの末尾スラッシュはproxyで処理する
  • 日本語URLは、デコード → NFC → 末尾スラッシュの順にそろえてから比較する
  • 最後に、対応表の全件にcurlして、転送先まで確かめる

WordPressからWordPressへの移行でURLを1対1で引き継ぐ話は、こちらにまとめています。

参考