旧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する
先に結論です。
- 切り替えの前に、旧サイトのsitemapから記事URLを全件抽出して、対応表としてリポジトリに置きます。
- proxyで、対応表に一致したパスだけを、sitemapに載っていたとおりのURLへ301で転送します。新しいサイトのページには触れません。
- 末尾スラッシュの自動リダイレクトがproxyより先に動くので、
skipTrailingSlashRedirectで止め、スラッシュの扱いをproxyで引き受けます。 - 日本語を含む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(/&/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で引き継ぐ話は、こちらにまとめています。
