本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。
要点
調査した限りでは、次のような結果になりました。
- Next.js の
output: 'export'を Vercel にデプロイ済みで、SEO 統合のためにvercel.appドメインへのアクセスを別ドメイン(今回はyour-org.github.io/<repo>/)へ全部 308 (permanent: trueのデフォルト) で転送したい、というケースを扱いました - 一見すると
vercel.jsonのredirectsに 1 行書けば終わりそうですが、実際には Vercel 側の挙動と相互作用して 5〜6 段階のハマり方をしました - 最終的に動いた構成は以下の組み合わせです。
framework: nullで Vercel の Next.js 自動検出を切るbuildCommand: "true"で Next.js のビルドをスキップ- 出力ディレクトリには コミット済みの最小 HTML 1 枚 を置いて「Output Directory is empty」回避と
/用フォールバックを兼ねる redirectsのsourceは/:rest(.*)の named regex 形式にして trailing slash 付きパス (/ja/) も全て拾う
最終的に動作した vercel.json
先に結論の設定例を示します。
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": null,
"buildCommand": "true",
"outputDirectory": "vercel-static",
"redirects": [
{
"source": "/:rest(.*)",
"destination": "https://your-org.github.io/my-app/:rest",
"permanent": true
}
]
}
vercel-static/index.html には、JS が無効でも動く meta-refresh と canonical を仕込んだ最小ページを置いています。
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Moved to your-org.github.io/my-app/</title>
<link rel="canonical" href="https://your-org.github.io/my-app/" />
<script>
(function () {
var target =
'https://your-org.github.io/my-app/' +
window.location.search +
window.location.hash;
window.location.replace(target);
})();
</script>
<meta http-equiv="refresh" content="0; url=https://your-org.github.io/my-app/" />
</head>
<body>
Moved to <a href="https://your-org.github.io/my-app/">your-org.github.io/my-app/</a>.
</body>
</html>
これで vercel.app の各 URL は次のように振る舞います。
| Vercel 側のリクエスト | 結果 |
|---|---|
/ | 200 を返し、HTML 内の JS が location.search + location.hash を付けて新ドメインへ location.replace |
/ja/, /en/, /任意のパス | vercel.json の 308 リダイレクトが発火、クエリ文字列も destination 側に引き継がれる(観察した範囲) |
背景
経緯としては、Next.js 製の静的サイト(output: 'export')を Vercel と GitHub Pages の両方にデプロイしていたものを、ドメインオーソリティと運用を集約する目的で GitHub Pages に一本化したい、というところから始まりました。
- 同じ内容が 2 つの URL(
<project>.vercel.appとyour-org.github.io/<repo>/)で公開されていると、被リンク・クロール・参照シェアが分散してしまう - Vercel と GitHub Pages はホスト名がそれぞれ別なので、
<link rel="canonical">を片方に揃えても検索エンジン側の解釈に頼ることになる - 既存の被リンクを切らずに集約したいので、Vercel を「リダイレクト専用」の存在に格下げするのが扱いやすい
そこで vercel.json の redirects 1 行で済ませようとしたところ、想像以上に詰まったというのが本題です。
失敗 1: 単純な redirects だけだと既存の静的ファイルが優先される
最初に書いたのは、redirects を 1 件入れただけのシンプルな設定でした。
{
"redirects": [
{
"source": "/:path*",
"destination": "https://your-org.github.io/my-app/:path*",
"permanent": true
}
]
}
output: 'export' の Next.js ビルドはそのままなので、out/index.html out/ja/index.html out/en/index.html といった静的ファイルが Vercel に配信されます。
挙動を curl -sI で確認すると次の通りでした。
GET / → 200 (out/index.html を配信、リダイレクトされない)
GET /ja/ → 200 (out/ja/index.html を配信、リダイレクトされない)
GET /missing → 308 → https://your-org.github.io/my-app/missing ←ここだけ働く
Next.js のドキュメント (next.config.js の redirects の項) には「Redirects are checked before the filesystem which includes pages and /public files」と書かれており、Vercel 側もこれに準じた挙動を期待していました。しかし実態としては 静的ファイルが存在するパスでは redirects が無視され、ファイルが無いパスにだけ redirects が当たる、というように見えました。少なくとも output: 'export' のプロジェクトでは、Next.js 統合側で各 export ファイルがルートとして登録されているためか、vercel.json の redirects が後追い扱いになるようです。
参考: Vercel Configuration - redirects / Next.js - redirects in next.config.js
ここで方針を「静的ファイル自体を出力しない」に切り替えます。
失敗 2: empty out + Next.js 検出で「Next.js output directory "out" is empty」
ビルドを空ディレクトリを作るだけに置き換えてみます。
{
"buildCommand": "mkdir -p out",
"outputDirectory": "out",
"redirects": [
{
"source": "/:path*",
"destination": "https://your-org.github.io/my-app/:path*",
"permanent": true
}
]
}
これで Vercel のビルドログにエラーが出ます。
Error: The Next.js output directory "out" exists but is empty. This is usually caused by one of the following:
1. If using Turborepo, ensure your task outputs include the Next.js build directory.
2. The build command did not generate any output. Check the build logs above for errors.
3. A previous build step may have cleared the output directory.
package.json に next 依存があると、Vercel は自動で「Next.js プロジェクト」として認識し、エクスポート結果が空であることをエラー扱いします。フレームワーク検出を切る必要がありました。
失敗 3: framework: null でも汎用「Output Directory is empty」
そこで framework: null を加えます。
{
"framework": null,
"buildCommand": "mkdir -p out",
"outputDirectory": "out",
"redirects": [...]
}
エラーメッセージは確かに変わりますが、今度は汎用の「Output Directory が空」チェックに引っかかります。
Error: The Output Directory "out" is empty.
Learn More: https://vercel.link/missing-public-directory
framework: null で Next.js 固有の検証は外れたものの、Vercel 全体のデプロイパイプラインは「成果物が空のデプロイは認めない」というポリシーになっているようです。出力ディレクトリには少なくとも 1 ファイル必要です。
失敗 4: buildCommand に長文インライン HTML → 256 文字制限
ならば buildCommand の中で printf を使ってその場で out/index.html を作ろうとしました。HTML には meta-refresh と canonical を入れて、/ だけはこの HTML で GitHub Pages へ飛ばす想定です。
{
"buildCommand": "mkdir -p out && printf '%s' '<!doctype html>...(meta-refresh とリンク)...' > out/index.html"
}
vercel.json の schema バリデーションでまた落ちます。
The `vercel.json` schema validation failed with the following message:
`buildCommand` should NOT be longer than 256 characters
buildCommand は 256 文字までという制限があるため、HTML のような長い文字列をインラインで埋め込むのは不可能です。
解決: 静的 HTML はコミットして buildCommand は no-op に
シンプルに、HTML をリポジトリにコミットしてしまいます。配置場所はビルド対象と被らないように vercel-static/index.html のような独立した名前にしておきます。
vercel-static/
└── index.html ← 上に貼った最小 HTML
vercel.json 側は最小限です。
{
"framework": null,
"buildCommand": "true",
"outputDirectory": "vercel-static",
"redirects": [
{
"source": "/:path*",
"destination": "https://your-org.github.io/my-app/:path*",
"permanent": true
}
]
}
buildCommand: "true" はシェルの true 組み込みコマンド(常に成功)を指定して、Vercel のビルドを実質スキップしています。outputDirectory で指す vercel-static/ はリポジトリにそのまま入っている既存ディレクトリなので、Vercel はそれをデプロイ成果物として扱います。
ここまでで Vercel のビルド自体は通るようになりました。
失敗 5: /:path+ だと /ja/ が 404
挙動を確認すると、/ /ja /ja/x は期待通り動くものの、trailing slash 付きの /ja/ だけ 404 になりました。
GET / → 200 (vercel-static/index.html)
GET /ja → 308 → https://your-org.github.io/my-app/ja ✅
GET /ja/x → 308 → https://your-org.github.io/my-app/ja/x ✅
GET /ja/ → 404 ❌
GET /this-is-missing-xyz → 308 (リダイレクトは効いている) ✅
/:path+ (path-to-regexp の「1 個以上のセグメント」量子詞) は、Vercel の解釈では trailing slash を伴う /ja/ を拾えていないように見えました。挙動としては Vercel が vercel-static/ja/index.html を探しに行き、見つからずに 404 を返している形です。/:path+ を /:path* (0 個以上)に変えても結果は同じで、/ja/ だけが宙に浮きます。
失敗 6: regex 形式 :rest(.*) で全パターン捕捉
最終的には named regex 形式を使うことで /ja/ を含む全てのパスで redirects が当たるようになりました。
{
"redirects": [
{
"source": "/:rest(.*)",
"destination": "https://your-org.github.io/my-app/:rest",
"permanent": true
}
]
}
:rest(.*) は「:rest という名前で (.*) 正規表現にマッチさせる」という path-to-regexp の named-regex 形式です。Vercel の redirects ドキュメントにも /:path((?!uk/).*) といった同形の例が出ているので、source フィールドでそのまま使えます。これにより「リーディングスラッシュ以降のあらゆる文字列」を 1 つの変数に取れるようになり、trailing slash 込みの /ja/ も rest=ja/ として 1 件のマッチになるため、destination 側で your-org.github.io/my-app/ja/ に展開されます。
挙動の確認結果はこのようになりました。
GET / → 200 (vercel-static/index.html、JS で / に refresh)
GET /ja/ → 308 → https://your-org.github.io/my-app/ja/ ✅
GET /ja/?url=https://… → 308 → https://your-org.github.io/my-app/ja/?url=https%3A%2F… ✅
GET /ja/about/ → 308 → https://your-org.github.io/my-app/ja/about/ ✅
観察した限り、クエリパラメータも Vercel 側で適切にエスケープされた上で destination に引き継がれていました (: / は %3A %2F)。GitHub Pages 側の Next.js アプリは URLSearchParams.get('url') で自動デコードして受け取れるため、リダイレクトを跨いだ深いリンク(例: ?url=... で外部 API を指定するタイプの URL)もそのまま生存します。なお、この query 自動転送は Next.js 側の next.config.js redirects ドキュメントでは明文化されていますが、Vercel の vercel.json redirects ドキュメントにはそこまで明記されていないので、本番運用前にご自身の構成でも 1 度確認しておくと安心です。
/ 用 HTML の役割
vercel-static/index.html を残している理由は 2 つあります。
- Vercel の「Output Directory is empty」チェックを通すための実体ファイル: 1 ファイルでも置いておけばデプロイが通ります
/(ルート)にアクセスされたときのクエリ + ハッシュ転送: Vercel のredirectsは/自体には適用されないケースが観察されたため(静的ファイルが先勝するか、:rest(.*)でrest=""になって安全側に倒される)、HTML 内でwindow.location.searchとwindow.location.hashを組み立ててlocation.replaceするスクリプトを入れています
<meta http-equiv="refresh"> は JS が無効な環境用のフォールバックで、<link rel="canonical"> は SEO 観点で「正規 URL は GitHub Pages 側」と明示しておくためです。
Vercel 仕様メモ(観察ベース)
ドキュメントに明記されていない(あるいは明記されていても実態が異なる)挙動として、今回手元で観察したものをまとめておきます。
output: 'export'の Next.js プロジェクトでは、vercel.jsonのredirectsよりも export 後の静的ファイルが優先されることがあるpackage.jsonにnext依存があるだけで Vercel は Next.js プロジェクトとして検出し、out/の中身が空だとビルド失敗扱いになる。framework: nullで検出を切れるframework: nullでも汎用の「Output Directory is empty」検証は走るため、最低 1 ファイルは出力が必要buildCommandは 256 文字以下の制約があるredirectsのsource: "/:path*"や"/:path+"は trailing slash 付きのパス(特に/ja/のようなロケールセグメント)でうまく動かないケースがある。"/:rest(.*)"の named regex 形式が最も素直に拾えるredirectsの destination では、観察した範囲で Vercel が query string を引き継ぎ、必要に応じて URL エンコードもしてくれる(Vercel docs では明文化されていない)permanent: trueのとき返るのは 308 (permanent: falseなら 307)。301 を返したい場合はstatusCode: 301を明示する
まとめ
Vercel から別ドメインへ全 URL を 308 で転送したいだけの「単純なはずの要件」でも、output: 'export' の Next.js プロジェクトを土台にすると、フレームワーク検出・出力ディレクトリ検証・buildCommand の文字数制限・source の path-to-regexp 解釈と、Vercel 側のレイヤーが複数絡んで思った通りに動かないことがあります。
同じことをやろうとしている方の参考になるよう、最終的に動いた設定と、その手前の各失敗の症状を時系列で残しました。検証は 2026-05-27 時点の Vercel CLI 54.x で行っています。



コメント
…