TLDR: VERCEL_URL is set in production too — it's a per-deploy hash, not your real domain. Gate your metadataBase on VERCEL_ENV === "production" instead, or you'll quietly bake an ephemeral URL into every canonical and og:image you generate.

I was deep in the rebuild of my personal site — Next.js 16 (Next.js, my React framework), OG images on every page, per-page canonicals, sitemap.xml, the whole SEO stack.

And I almost shipped a version where every single canonical URL and og:image path pointed at a URL that would change with every deployment.

No errors. Build passed. Everything looked fine.

It was WRONG in the quietest possible way.

The setup

Next.js has a concept called metadataBase — a URL you set in your root layout that acts as the base for resolving relative paths in alternates.canonical, openGraph.images, and your sitemap.

You need it. Without it, relative paths stay relative and your og:image cards don't work.

The obvious thing to set it to is the current host. Vercel (the hosting platform) injects a VERCEL_URL environment variable into every deployment. That's the deployment host, right?

Right. But not the one you want.

The trap

VERCEL_URL is the per-deployment hash URL — something like mysite-abc123-myteam.vercel.app.

It changes. Every push.

And here's the part that got me: it's set in production too. Not just on preview deploys. Every environment gets it.

So if your first instinct is new URL("https://" + process.env.VERCEL_URL) — your production build quietly bakes that hash URL into every canonical and og:image it emits. Different URL after every deploy. Google crawls a new canonical on every push. Your og:image links rot between deploys.

And your build still passes. And your tests still pass.

I caught it before DNS cutover — a second look at the generated <head> surfaced it.

The fix

The variable you actually want is VERCEL_ENV. That tells you which environment you're in (production, preview, development). VERCEL_URL just tells you what this particular deployment's hash host happens to be.

Here's the exact config I landed on in lib/site.ts:

export const siteUrl =
  process.env.VERCEL_ENV === "production"
    ? "https://mysite.com"
    : process.env.VERCEL_URL
      ? `https://${process.env.VERCEL_URL}`
      : "https://mysite.com";

Three-way gate:

  • Production → hardcode the real domain. Always.
  • Preview → use VERCEL_URL. This is intentional — you want the preview deploy's real host so you can share the link and validate your og:image card before DNS cutover.
  • Local → fall back to the real domain.

The preview branch is the one people skip. But it's actually useful — a real URL on your preview deploy means you can paste it into the Twitter card validator or a Slack message and confirm the card renders correctly before you ever point real traffic at it.

Why this one stings

Build-time artifacts — metadataBase, canonicals, sitemaps, OG images — have no request context. One build, one set of baked URLs, served to every visitor and every crawler identically. There's no runtime origin to read. Whatever you bake in is what ships.

I ran into the same pattern on a law firm client project where a generated engagement letter was rendering against the Vercel deployment URL instead of the production domain. Same trap, different surface.

VERCEL_ENV and VERCEL_URL sound like they're related. They are not the same thing. One names the environment, one names the deployment. Gate your canonical config on the environment, or you're handing Google a different URL every time you push.