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:
X-Forwarded-Hostfirst → fall back toHostX-Forwarded-Protofirst → otherwise inferred from listening port (80=http / 443=https)
So the reverse proxy must forward all three of these to the Node upstream:
| Header | Purpose | Required |
|---|---|---|
Host | Primary domain | Yes |
X-Forwarded-Host | Forwards original Host when behind multiple proxies | Recommended |
X-Forwarded-Proto | Forwards https scheme | Yes (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:
- 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
- SSL/TLS mode: must be "Full (strict)" — otherwise cloudflare may not send X-Forwarded-Proto
- 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:
| Scenario | Who configures | What value |
|---|---|---|
| Local dev | developer | http://localhost:3000 or a staging URL (e.g. https://test.example.com) |
| Staging deploy | DevOps | staging domain (e.g. https://test.example.com) |
| Production deploy | ops / DevOps | officially 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):
| Input | Result |
|---|---|
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 fromnuxt-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.xmlreturns URLs on its own domain and gets indexed independently (verified with two different Host headers — each<loc>follows).