Our blog told Google its canonical URL was localhost:3000

A prerendered Nuxt blog baked the dev site URL into every og:url and JSON-LD @id. Why it took two fixes, and the one-line rule that finally stuck.

June 9, 2026

We shipped a Markdown blog, ran a production build, opened the generated HTML to spot-check it, and found this stamped into every post:

<link rel="canonical" href="http://localhost:3000/blog/hello-world">

The JSON-LD right below it agreed:

"mainEntityOfPage": { "@type": "WebPage", "@id": "http://localhost:3000/blog/hello-world" }

A self-canonical to localhost is close to the worst signal you can hand a crawler for a page you actually want indexed. It names a host Google can never reach and declares "the real version of this page lives there, not here." Nothing 404s, nothing throws, the page looks perfect in a browser. It just quietly tells the index to ignore itself.

Here is how a dev-only URL ended up in a production artifact, why the first fix did not hold, and the rule we landed on.

Prerendering freezes whatever the build happened to see

The blog runs on @nuxt/content, and the posts are prerendered. That is the right call for this stack: on Vercel's serverless runtime the sqlite driver that queryCollection needs does not exist at request time, so we generate the HTML at build time and serve static files. No cold-start query, no per-request cost.

The cost is somewhere else. Prerendering runs your page code exactly once, during the build, and writes the result to disk. Every value the page reads gets soldered into that file. For static content that is the point. For anything that depends on where the build ran, it is a landmine.

Our canonical was built from runtimeConfig.public.siteUrl. The word "runtime" is the whole trap. On a server-rendered request that value genuinely is per-request, read fresh from the deploy environment. A prerendered page has no request. It reads siteUrl once, at build, and that single resolution is what every future visitor receives.

In local dev NUXT_PUBLIC_SITE_URL defaults to http://localhost:3000. So a production build kicked off from a laptop, with a dev .env sitting next to it, wrote localhost:3000 into every canonical and every JSON-LD @id on the blog. Our landing page never showed the bug because it is server-rendered, not prerendered. It reads siteUrl on each request, and in production that resolves to the real origin. The two render modes diverged on the exact value that matters most for SEO.

First fix: stop reading a runtime value

The diagnosis felt complete. Stop reading a per-request config value for something that gets frozen at build time. Read a build-time constant instead.

Nuxt's site config looked perfect for that. useSiteConfig().url comes from the site.url field in nuxt.config, resolved once at build rather than per request. We pointed the blog pages at it, the canonical now read https://boiledplate.ai, and we moved on.

Then the next production build from the same machine produced the same localhost canonical.

Why the build constant was still the dev URL

The wrong assumption hid one layer down: where site.url got its value. We had wired nuxt.config's site.url to fall back to the same NUXT_PUBLIC_SITE_URL env var. So useSiteConfig().url was a build constant in the strict sense, but it was a build constant computed from the dev environment variable. On a local vercel --prod build that variable is still http://localhost:3000. We had swapped which function read the value without touching the poisoned input feeding it.

That is the part worth keeping: "frozen at build time" and "always production" are not the same property. A value can be a perfectly good build constant and still be wrong, because what it froze was your laptop's dev config. The canonical URL belongs to a small set of values that must equal the production origin no matter where the build runs, a teammate's machine, a vercel --prod off a branch, CI, anywhere. Anything an environment can override fails that bar, including a constant derived from something an environment can override.

The fix that stuck: one literal, one source of truth

So we stopped deriving the production origin and just stated it. A single file, shared/site.ts:

// The canonical production origin: the single source of truth for the
// public site URL literal.
export const SITE_ORIGIN = 'https://boiledplate.ai'

nuxt.config's site.url reads this constant, and the blog pages import it directly for canonical and JSON-LD:

import { SITE_ORIGIN } from '#shared/site'

// Canonical/JSON-LD use the production origin constant. These pages are
// prerendered, and a local `vercel --prod` build would otherwise bake the
// dev siteUrl.
const siteUrl = SITE_ORIGIN

It lives in shared/ because that directory is visible to both the app and server TypeScript projects, the same reason our generated database types live there. One literal, imported by everything that emits a public-facing absolute URL on a prerendered page. There is no environment variable anywhere in that path, so there is nothing for a stray dev .env to poison.

Runtime SSR keeps reading runtimeConfig.public.siteUrl, which the deploy environment still overrides per environment. That is correct: those pages render per request and should reflect where they are actually running. Only the prerendered, must-always-be-production URLs use the hardcoded origin. We confirmed the fix by reading the prerendered HTML from a local production build and checking that both the canonical and the @id resolved to https://boiledplate.ai.

The takeaway

If a page is prerendered, treat every absolute URL it emits as frozen at build time and ask one question: would this value still be correct if the build ran on my laptop? For a canonical tag the answer has to be yes, unconditionally, which means it cannot come from anything an environment can change. A runtime config value fails that test. A value derived from a runtime config value also fails it, and that is the layer that fooled us into thinking we were done.

The blog ships alongside a sitemap fed from the same build-time route scan and BlogPosting JSON-LD on every post, so the same literal now backs all three. If you are wiring up the same stack, the canonical-on-prerender bug is the one that will not appear in dev and will not throw in production. Open the generated HTML and read the <link rel="canonical"> with your own eyes before you trust it.

#nuxt #seo #prerendering #ssg

Read more