This article is co-authored with generative AI. I've cross-checked the factual claims against official documentation where possible, but errors may remain. Please verify against primary sources before relying on this for any important decision.
Summary
Findings from the investigation:
- Starting point: a Next.js project with
output: 'export'deployed to both Vercel and GitHub Pages. To consolidate SEO and reduce operational surface, the goal was to keep GitHub Pages as the canonical host and have allvercel.appURLs redirect there with a 308 (permanent: trueinvercel.jsonreturns 308 by default). - This sounds like a one-line job in
vercel.json, but it actually took five to six iterations before everything worked. - The configuration that ended up working combines:
framework: nullto disable Vercel's Next.js auto-detectionbuildCommand: "true"to skip the Next.js build entirely- A committed minimal HTML file in the output directory, which both satisfies Vercel's "non-empty output" check and serves as the fallback for
/ - A
redirectsrule whosesourceuses the named-regex form/:rest(.*)so trailing-slash paths like/ja/also match
Final working vercel.json
The end-state configuration:
{
"$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 is a minimal page that does a JS redirect (so query strings and hash fragments survive) with a meta-refresh fallback and a canonical link:
<!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>
With this in place, vercel.app URLs behave as follows:
| Request on Vercel | Result |
|---|---|
/ | 200, the HTML's JS redirects to the new domain with location.search + location.hash appended |
/ja/, /en/, /anything | 308 from the vercel.json redirect; the query string is carried over to the destination in the cases I observed |
Background
The setup that motivated this: a Next.js static export ran on both Vercel and GitHub Pages, and I wanted to consolidate the two onto GitHub Pages.
- When the same content is reachable from two URLs (
<project>.vercel.appandyour-org.github.io/<repo>/), inbound links, crawler attention, and shared URLs end up split between them. - The two hosts use different domain names, so
<link rel="canonical">alone only signals intent to search engines — actual consolidation depends on each engine honoring the hint. - To preserve existing inbound links rather than breaking them, the cleanest option is to demote Vercel to a redirect-only surface.
What follows are the failures I hit while trying to express that one-line idea in vercel.json.
Failure 1: With only redirects, existing static files win
The first attempt was the obvious one — just a single redirects rule:
{
"redirects": [
{
"source": "/:path*",
"destination": "https://your-org.github.io/my-app/:path*",
"permanent": true
}
]
}
The Next.js export still runs, so out/index.html, out/ja/index.html, out/en/index.html, and friends are all part of the deployment.
What I observed with curl -sI:
GET / → 200 (serves out/index.html, no redirect)
GET /ja/ → 200 (serves out/ja/index.html, no redirect)
GET /missing → 308 → https://your-org.github.io/my-app/missing ← only this works
Next.js's documentation for next.config.js redirects says "Redirects are checked before the filesystem which includes pages and /public files", and I had expected Vercel to behave similarly. Empirically though, paths that have a matching static file bypass redirects entirely, and redirects only fires for paths that don't have one. At least for output: 'export' projects, the per-file routes the Next.js integration registers seem to take precedence over vercel.json redirects.
Refs: Vercel Configuration - redirects / Next.js - redirects in next.config.js
So the next idea was: don't ship any static files at all.
Failure 2: Empty out/ + Next.js detection → "Next.js output directory is empty"
Replace the build with one that just creates an empty directory:
{
"buildCommand": "mkdir -p out",
"outputDirectory": "out",
"redirects": [
{
"source": "/:path*",
"destination": "https://your-org.github.io/my-app/:path*",
"permanent": true
}
]
}
Vercel's build now errors out:
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.
When package.json lists next as a dependency, Vercel automatically classifies the project as Next.js and treats an empty export as a build failure. The framework detection needs to be turned off.
Failure 3: framework: null only changes the error message
Add framework: null:
{
"framework": null,
"buildCommand": "mkdir -p out",
"outputDirectory": "out",
"redirects": [...]
}
The error message does change, but now we hit Vercel's generic non-Next.js "empty output" check:
Error: The Output Directory "out" is empty.
Learn More: https://vercel.link/missing-public-directory
framework: null does turn off the Next.js-specific validation, but Vercel's deployment pipeline as a whole still refuses to ship an empty output directory. At least one file is required.
Failure 4: Inlining HTML in buildCommand → 256-character limit
The next attempt put a printf in buildCommand to materialize a minimal out/index.html at build time — a meta-refresh + canonical page so that / would redirect to GitHub Pages.
{
"buildCommand": "mkdir -p out && printf '%s' '<!doctype html>...(meta-refresh + link)...' > out/index.html"
}
That hits the vercel.json schema validator:
The `vercel.json` schema validation failed with the following message:
`buildCommand` should NOT be longer than 256 characters
buildCommand is capped at 256 characters, so inlining anything HTML-sized isn't workable.
Solution: Commit the static HTML, leave buildCommand as a no-op
The cleanest workaround is to commit the redirect HTML to the repository. Put it somewhere that doesn't collide with the Next.js build output — for example, vercel-static/index.html:
vercel-static/
└── index.html ← the minimal HTML shown above
The vercel.json becomes minimal:
{
"framework": null,
"buildCommand": "true",
"outputDirectory": "vercel-static",
"redirects": [
{
"source": "/:path*",
"destination": "https://your-org.github.io/my-app/:path*",
"permanent": true
}
]
}
buildCommand: "true" invokes the shell true builtin (always succeeds), so the Vercel build phase becomes a no-op. outputDirectory: "vercel-static" points at a directory that's already in the repo, and Vercel ships it as the deployment artifact.
The build itself succeeds at this point.
Failure 5: /:path+ doesn't match /ja/
The deploy goes through, but checking the actual behavior reveals that the trailing-slash case /ja/ returns 404 while neighboring paths work:
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 (the redirect itself is wired up correctly) ✅
/:path+ (path-to-regexp's "one or more segments" quantifier) doesn't pick up /ja/ with the trailing slash under Vercel's interpretation. What it looks like in practice: Vercel ends up looking for vercel-static/ja/index.html, doesn't find it, and returns 404.
Switching to /:path* doesn't change the behavior. /ja/ is still the odd one out.
Failure 6: Catch everything with the regex form /:rest(.*)
What finally worked was the named-regex form:
{
"redirects": [
{
"source": "/:rest(.*)",
"destination": "https://your-org.github.io/my-app/:rest",
"permanent": true
}
]
}
:rest(.*) is path-to-regexp's named-regex form for "capture the rest of the path under the name :rest using the (.*) regex". Vercel's redirects docs use the same shape in their own examples (/:path((?!uk/).*)), so it's accepted directly in source. The effect is that the rule matches everything after the leading slash, trailing slash included. /ja/ is captured as rest=ja/ and gets rewritten to your-org.github.io/my-app/ja/.
End-to-end results after this change:
GET / → 200 (vercel-static/index.html; JS redirects with search + hash)
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/ ✅
In the cases I observed, Vercel also percent-encoded and carried the query string over to the destination (: → %3A, / → %2F). The Next.js app on GitHub Pages can read them back unchanged through URLSearchParams.get('url'), so deep links of the form ?url=… survive the redirect intact. The query forwarding behavior is documented for Next.js next.config.js redirects but isn't explicitly documented on Vercel's vercel.json page, so it's worth verifying it on your own setup before relying on it in production.
Why keep an index.html at /
vercel-static/index.html exists for two reasons:
- To satisfy Vercel's "Output Directory is empty" check. A single file is enough.
- To carry query + hash from
/to the new domain. Empirically, thevercel.jsonredirectsrule does not always fire for/itself (either the static file wins, or:rest(.*)resolves torest=""and the rewrite is harmless). The HTML's inline script builds the target URL fromwindow.location.search+window.location.hashand callslocation.replace.
<meta http-equiv="refresh"> is the fallback for clients without JS, and <link rel="canonical"> tells search engines that the canonical URL lives on GitHub Pages.
Notes on Vercel behavior (empirical)
Behaviors I observed in passing that aren't always clear from the docs:
- For Next.js projects built with
output: 'export', the export's static files can take precedence oververcel.jsonredirects, even though the docs suggestredirectsis evaluated first. - Having
nextinpackage.jsonis enough for Vercel to auto-detect the project as Next.js and run Next.js-specific validation (including the "empty output" check).framework: nulldisables this. - Even with
framework: null, Vercel's generic deployment pipeline still requires at least one file in the output directory. buildCommandis capped at 256 characters.redirectssources like"/:path*"and"/:path+"don't reliably match trailing-slash paths such as/ja/. The named-regex form"/:rest(.*)"matches the widest range of inputs.- In the cases I observed, Vercel forwards query strings to redirect destinations and URL-encodes them as needed (this isn't explicitly stated in Vercel's
vercel.jsondocs; it is stated for Next.jsnext.config.jsredirects). permanent: truereturns 308 (permanent: falsereturns 307). If you actually need a 301, setstatusCode: 301explicitly.
Wrap-up
What sounds like a one-line "redirect everything to another domain" task turns out to interact with several Vercel layers — framework detection, output validation, the 256-character buildCommand limit, and the path-to-regexp dialect for redirects.source. Especially on top of a Next.js output: 'export' project, getting all paths (including trailing-slash variants) to redirect requires more configuration than the docs hint at.
I've documented the final config along with the symptoms of each earlier failure so that someone trying to do the same thing has a reference. Verified against Vercel CLI 54.x as of 2026-05-27.



Comments
…