nginx origin header forwarding

When you see the framework diagnostic [swift-web] SSR-rendered canonical origin doesn't match the browser's actual origin, this page covers the triage path plus example nginx / cloudflare fixes.

What the diagnostic means

useSeo()'s origin resolution is "single source of truth + explicit escape hatch + prerender exception" — three branches, in order:

L1. explicit useSeo({ siteUrl })       ← per-page override (skips this diagnostic)
L2. NUXT_PUBLIC_SITE_URL               ← set it to converge on that domain (opt-in; skips this diagnostic)
L3. useRequestURL().origin (default)   ← follows the domain the visitor actually used
      ├─ SSR Node side: parsed from Host / X-Forwarded-Host / X-Forwarded-Proto
      └─ After CSR hydration: equals window.location.origin

L2 is an opt-in convergence switch, not the default. Without NUXT_PUBLIC_SITE_URL the origin follows L3 — the domain the visitor used. That is the default multi-domain / anti-block mirror sites need (each mirror gets indexed on its own). Setting it turns on convergence to one primary domain.

⚠️ Prerendered pages are the one case that requires it: at build time there is no real request and therefore no Host, so the origin can only come from config (framework fails the build without it). Multi-domain sites can switch those pages to isr: 3600 to avoid being forced into the setting.

⚠️ This diagnostic only applies to L3 — under L1 / L2 a canonical that differs from the visited domain is expected, so the check is skipped.

canonical / og:url / og:image absolute paths all use this origin. If the reverse proxy doesn't forward the real Host to Node, SSR useRequestURL() falls back to the default Host header (usually an internal upstream like 127.0.0.1:3000) — silently polluting SEO.

So ops doesn't only discover this "months later," framework runs a one-time comparison during CSR hydration:

document.querySelector('link[rel="canonical"]').href.origin   ← what SSR wrote
                            vs
window.location.origin                                          ← what the browser actually saw

On mismatch, console emits:

  • dev: console.warn
  • prod: console.error

Once per session (module-level hasChecked deduplication). Business functionality is unaffected, but SEO / social share cards point to the wrong domain.

The 3 headers that must be forwarded

How Node-side useRequestURL() resolves:

  1. X-Forwarded-Host first → fall back to Host
  2. X-Forwarded-Proto first → otherwise inferred from listening port (80=http / 443=https)

So the reverse proxy must forward all three of these to the Node upstream:

HeaderPurposeRequired
HostPrimary domainYes
X-Forwarded-HostForwards original Host when behind multiple proxiesRecommended
X-Forwarded-ProtoForwards https schemeYes (when HTTPS is enabled)

nginx example

Simplest form, plain nginx reverse-proxying to pm2/Node:

server {
  listen 443 ssl http2;
  server_name www.example.com api.example.com;  # all mirror domains share this location
  ssl_certificate ...;
  ssl_certificate_key ...;

  location / {
    proxy_pass http://127.0.0.1:3000;

    # ─── Critical 3 lines: forward original domain + scheme ──────
    proxy_set_header Host              $host;              # the user-visited domain (use $host, NOT $proxy_host)
    proxy_set_header X-Forwarded-Host  $host;              # explicit backup (for multi-layer proxies)
    proxy_set_header X-Forwarded-Proto $scheme;            # https or http
    # ─────────────────────────────────────────────────────────────

    # other usual forwards
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;

    # WebSocket / HTTP 1.1 upgrade (needed by Nitro / Vite HMR)
    proxy_http_version 1.1;
    proxy_set_header Upgrade           $http_upgrade;
    proxy_set_header Connection        "upgrade";
  }
}

Common mistakes:

# ❌ Using $proxy_host (that's the proxy target, equals 127.0.0.1:3000)
proxy_set_header Host $proxy_host;

# ❌ Hardcoding the domain (mirror domains will get wrong values)
proxy_set_header Host www.example.com;

# ❌ Missing X-Forwarded-Proto, making an https site's canonical resolve to http://...
# (SEO impact: http vs https are treated as different sites, splitting domain authority)

cloudflare notes

If cloudflare sits in front of nginx:

  1. Host header behavior: cloudflare by default rewrites the client's original Host to your origin domain (controlled at Network → Origin Server Properties → "Host Header"). Set to "Preserve original Host header" so nginx also sees what the user actually typed
  2. SSL/TLS mode: must be "Full (strict)" — otherwise cloudflare may not send X-Forwarded-Proto
  3. Multi-mirror domains + Cloudflare: each mirror domain must be added to cloudflare separately, with proxy enabled (orange cloud)

Triage commands

1. Use curl to simulate a specific Host

# Simulate visiting www.example.com (instead of the raw IP)
curl -H "Host: www.example.com" http://your-vps-ip/some-page | grep canonical

# Expected: <link rel="canonical" href="http://www.example.com/some-page" />
# Got http://127.0.0.1:3000/... = reverse proxy not forwarding Host

2. Print useRequestURL on the Node side

Temporarily add this to a test page:

// app/pages/tests/_origin-debug.vue
<script setup>
const url = useRequestURL()
console.log('[SSR origin]', url.origin, 'hostname:', url.hostname, 'proto:', url.protocol)
</script>

Deploy to prod, visit once, then check Node logs (pm2 logs) — does the printed origin match expectations?

3. Confirm in the browser

// Run in the user-facing page console:
const c = document.querySelector('link[rel="canonical"]').href
console.log('canonical:', new URL(c).origin)
console.log('browser :', window.location.origin)

The two should match; mismatch = nginx forwarding misconfigured.

Business-side bypass (emergency ops fix)

Ops fix needs time but SEO is urgent? Pass siteUrl explicitly when calling useSeo() to lock the origin temporarily:

useSeo({
  title: '...',
  description: '...',
  siteUrl: 'https://www.example.com',   // explicit override
})

Passing input.siteUrl makes framework skip the origin consistency check for that page (treated as caller-initiated lock). Once nginx is fixed, drop this temporary config to return to "single source of truth + automatic diagnostic" mode.

NUXT_PUBLIC_SITE_URL config {#site-url-config}

The NUXT_PUBLIC_SITE_URL env var serves two purposes: ① when set, sitemap.xml's siteUrl converges on it (when unset, nuxt-site-config infers it from the request origin — not "locked"; multi-domain sites must set it, see "Same resolution as useSeo" below); ② the useSeo origin of prerendered / SSG pages also reads it (no real request exists at prerender time) — a missing env throws at build time in that scenario. framework additionally runs a host-format check at module setup; on failure it console.warns in dev or console.errors in production build (the format check itself does not block the build; what blocks is a prerender page missing the env).

Who provides this value:

ScenarioWho configuresWhat value
Local devdeveloperhttp://localhost:3000 or a staging URL (e.g. https://test.example.com)
Staging deployDevOpsstaging domain (e.g. https://test.example.com)
Production deployops / DevOpsofficially assigned primary domain (e.g. https://www.example.com)

Why ops / DevOps owns this, not developers:

  • The primary domain is a business asset (ICP filing, SSL certs, CDN config all tied to it)
  • Mirror / staging domains are also ops/DevOps decisions (developers shouldn't assume)
  • Developers use localhost locally — no need to "know" the production domain

Validation rules (strict host format):

InputResult
undefined / ''❌ not configured
example.com (missing protocol)❌ not a valid URL
<your-domain> (placeholder not replaced)❌ not a valid URL
ftp://example.com❌ protocol must be http/https
https://example.com/api (with path)❌ must not include path
https://example.com?lang=zh (with query)❌ must not include query/hash
https://example.com✅
https://example.com/ (trailing slash)✅
http://localhost:3000 (dev)✅

Same resolution as useSeo (the old wording — "sitemap always uses NUXT_PUBLIC_SITE_URL" — was wrong and has been corrected): the sitemap's domain comes from nuxt-site-config's priority stack — nitro(-4) pushes the request-derived origin, runtime(0) pushes the env, higher wins ⇒ env when set, request domain when empty, exactly like canonical / og:url.

⇒ Multi-domain / anti-block mirror sites should leave the variable empty: each mirror serving /sitemap.xml returns URLs on its own domain and gets indexed independently (verified with two different Host headers — each <loc> follows).

See also