本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

要点

調査した限りでは、次のような結果になりました。

  • Next.js の output: 'export' を Vercel にデプロイ済みで、SEO 統合のために vercel.app ドメインへのアクセスを別ドメイン(今回は your-org.github.io/<repo>/)へ全部 308 (permanent: true のデフォルト) で転送したい、というケースを扱いました
  • 一見すると vercel.jsonredirects に 1 行書けば終わりそうですが、実際には Vercel 側の挙動と相互作用して 5〜6 段階のハマり方をしました
  • 最終的に動いた構成は以下の組み合わせです。
    • framework: null で Vercel の Next.js 自動検出を切る
    • buildCommand: "true" で Next.js のビルドをスキップ
    • 出力ディレクトリには コミット済みの最小 HTML 1 枚 を置いて「Output Directory is empty」回避と / 用フォールバックを兼ねる
    • redirectssource/: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.appyour-org.github.io/<repo>/)で公開されていると、被リンク・クロール・参照シェアが分散してしまう
  • Vercel と GitHub Pages はホスト名がそれぞれ別なので、<link rel="canonical"> を片方に揃えても検索エンジン側の解釈に頼ることになる
  • 既存の被リンクを切らずに集約したいので、Vercel を「リダイレクト専用」の存在に格下げするのが扱いやすい

そこで vercel.jsonredirects 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.jsredirects の項) には「Redirects are checked before the filesystem which includes pages and /public files」と書かれており、Vercel 側もこれに準じた挙動を期待していました。しかし実態としては 静的ファイルが存在するパスでは redirects が無視され、ファイルが無いパスにだけ redirects が当たる、というように見えました。少なくとも output: 'export' のプロジェクトでは、Next.js 統合側で各 export ファイルがルートとして登録されているためか、vercel.jsonredirects が後追い扱いになるようです。

参考: 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.jsonnext 依存があると、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 つあります。

  1. Vercel の「Output Directory is empty」チェックを通すための実体ファイル: 1 ファイルでも置いておけばデプロイが通ります
  2. / (ルート)にアクセスされたときのクエリ + ハッシュ転送: Vercel の redirects/ 自体には適用されないケースが観察されたため(静的ファイルが先勝するか、:rest(.*)rest="" になって安全側に倒される)、HTML 内で window.location.searchwindow.location.hash を組み立てて location.replace するスクリプトを入れています

<meta http-equiv="refresh"> は JS が無効な環境用のフォールバックで、<link rel="canonical"> は SEO 観点で「正規 URL は GitHub Pages 側」と明示しておくためです。

Vercel 仕様メモ(観察ベース)

ドキュメントに明記されていない(あるいは明記されていても実態が異なる)挙動として、今回手元で観察したものをまとめておきます。

  • output: 'export' の Next.js プロジェクトでは、vercel.jsonredirects よりも export 後の静的ファイルが優先されることがある
  • package.jsonnext 依存があるだけで Vercel は Next.js プロジェクトとして検出し、out/ の中身が空だとビルド失敗扱いになる。framework: null で検出を切れる
  • framework: null でも汎用の「Output Directory is empty」検証は走るため、最低 1 ファイルは出力が必要
  • buildCommand は 256 文字以下の制約がある
  • redirectssource: "/: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 で行っています。