The first deploy of this site failed, and the error pointed at a line I was confident about:
> 28 | metadataBase: new URL(site.url),
| ^
{ code: 'ERR_INVALID_URL', input: '' }
Error: Failed to collect page data for /_not-foundThe interesting part of that output is input: ''. Not undefined. An empty string.
Locally the same code built fine, which is the detail that tells you what kind of bug this is. It isn’t a logic error. It’s an environment difference.
The variable was set, and that was the problem
I had added NEXT_PUBLIC_SITE_URL in the Vercel dashboard but left the value blank, planning to fill it in once the domain was attached. My config read it like this:
url: process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000",?? only falls back on null or undefined. An empty string is a perfectly good string, so the fallback never fired and new URL("") got called instead. new URL() requires an absolute URL, so it threw.
This is the part worth remembering: in a deployed environment, "unset" and "set to nothing" are different states, and most fallback code only handles the first one.
Why it failed on the 404 page
The error named /_not-found, which felt unrelated. It isn’t. metadataBase lives in the root layout’s exported metadata, and every route inherits that metadata — including the built-in not-found page. Next.js collects page data for each route at build time, so the first route it touched was the first one to blow up. Any route would have done it.
The fix treats blank as unset
function resolveSiteUrl(): string {
const configured = process.env.NEXT_PUBLIC_SITE_URL?.trim();
if (configured) {
return configured.replace(/\/$/, "");
}
const vercelHost =
process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim() ||
process.env.VERCEL_URL?.trim();
return vercelHost ? `https://${vercelHost}` : "http://localhost:3000";
}Three things are doing work here.
The truthiness check replaces ??, so an empty string and a string of spaces both fall through to the fallback instead of reaching new URL().
The trim() matters more than it looks. A value pasted into a dashboard field picks up whitespace easily, and a URL with a trailing space fails validation in a way that reads as a mystery.
Stripping the trailing slash keeps canonical URLs from doubling up later. If the configured value ends in a slash and every path is joined onto it, you get example.com//articles, which is a different URL to a crawler than the one in your sitemap.
Using Vercel's own host as the fallback
Rather than falling back to localhost in production, the config reaches for the host Vercel already knows.
VERCEL_PROJECT_PRODUCTION_URL is the project’s stable production domain, which is what you want for canonical URLs and metadata. VERCEL_URL is the unique host for that specific deployment, so it changes on every push — fine as a last resort, wrong as a canonical.
Neither is prefixed with a protocol, which is why the code prepends https:// rather than using the value directly.
One caveat that cost me a few minutes: these are system environment variables, and they’re only exposed to your build when that setting is enabled for the project. Treat them as a fallback, not a guarantee.
What I'd check first next time
When a build passes locally and fails on deploy, the useful question isn’t "what’s wrong with my code." It’s "what does the environment have that my machine doesn’t, or the reverse."
Here, my machine had no NEXT_PUBLIC_SITE_URL at all, so the fallback ran and everything worked. The deploy had the variable present and empty, which was the one case the code didn’t handle. A blank field in a dashboard is the easiest version of this to miss, because it looks configured.