Skip to content

Commit d2996b1

Browse files
s00dcursoragent
andcommitted
feat(meta): fall back to site.url for SEO base
When metaBaseUrl is unset, prefer nuxt-site-config site.url over the request origin so SEO stacks need one source of truth. Closes #240 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d182a92 commit d2996b1

8 files changed

Lines changed: 77 additions & 23 deletions

File tree

docs/api/module-options.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Meta tags generated for each localized page.
9191
| Option | Type | Default | Description |
9292
| --- | --- | --- | --- |
9393
| `meta` | `boolean` | `true` | Generate SEO meta tags (`hreflang`, `canonical`, `og:url`, `og:locale`) automatically. |
94-
| `metaBaseUrl` | `string` | `undefined` | Base URL for SEO meta tags (canonical, og:url, hreflang). - `undefined`dynamically resolved from the current request URL (`useRequestURL().origin` on server, `window.location.origin` on client). Best for multi-domain deployments. - A concrete URL string (e.g. `'https://example.com'`) — used as-is. |
94+
| `metaBaseUrl` | `string` | `undefined` | Base URL for SEO meta tags (canonical, og:url, hreflang). - A concrete URL string (e.g. `'https://example.com'`) — used as-is (highest priority). - `undefined`falls back to `site.url` from `nuxt-site-config` when that module is present, otherwise the current request origin (`useRequestURL().origin` on server, `window.location.origin` on client). |
9595
| `metaTrustForwardedHost` | `boolean` | `true` | Trust the `X-Forwarded-Host` header when resolving the base URL for meta tags. Enable when the app runs behind a reverse proxy (nginx, Cloudflare, AWS ALB, etc.) that sets this header to the real client-facing hostname. |
9696
| `metaTrustForwardedProto` | `boolean` | `true` | Trust the `X-Forwarded-Proto` header when resolving the protocol for meta tags. Enable when the app runs behind a TLS-terminating proxy so that canonical URLs use `https://` even though the app itself listens on HTTP. |
9797
| `canonicalQueryWhitelist` | `string[]` | `['page', 'sort', 'filter', 'search', 'q', 'query', 'tag']` | List of query parameter names preserved in canonical and `og:url` meta tags. Parameters not in this list are stripped from the canonical URL. |

docs/api/packages.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ or CI failing.
3030
| [`@i18n-micro/test-utils`](/api/packages/test-utils) | `@i18n-micro/test-utils`, `@i18n-micro/test-utils/publish-smoke` | 36 |
3131
| [`@i18n-micro/types`](/api/packages/types) | `@i18n-micro/types` | 26 |
3232
| [`@i18n-micro/types-generator`](/api/packages/types-generator) | `@i18n-micro/types-generator`, `@i18n-micro/types-generator/nuxt` | 7 |
33-
| [`@i18n-micro/utils`](/api/packages/utils) | `@i18n-micro/utils/accept-language`, `@i18n-micro/utils/active-locales`, `@i18n-micro/utils/app-path`, `@i18n-micro/utils/build`, `@i18n-micro/utils/cache-control`, `@i18n-micro/utils/cookie`, `@i18n-micro/utils/deep-merge`, `@i18n-micro/utils/merge-i18n-head`, `@i18n-micro/utils/merge-source`, `@i18n-micro/utils/normalize`, `@i18n-micro/utils/parse-path`, `@i18n-micro/utils/payload-config`, `@i18n-micro/utils/payload-fetch`, `@i18n-micro/utils/payload-stats`, `@i18n-micro/utils/payload-url`, `@i18n-micro/utils/resolve-hreflang`, `@i18n-micro/utils/resolve-locale`, `@i18n-micro/utils/resolve-og-locale`, `@i18n-micro/utils/route`, `@i18n-micro/utils/route-pattern`, `@i18n-micro/utils/runtime-config`, `@i18n-micro/utils/source-loader` | 97 |
33+
| [`@i18n-micro/utils`](/api/packages/utils) | `@i18n-micro/utils/accept-language`, `@i18n-micro/utils/active-locales`, `@i18n-micro/utils/app-path`, `@i18n-micro/utils/auto-detect-path`, `@i18n-micro/utils/build`, `@i18n-micro/utils/cache-control`, `@i18n-micro/utils/cookie`, `@i18n-micro/utils/deep-merge`, `@i18n-micro/utils/merge-i18n-head`, `@i18n-micro/utils/merge-source`, `@i18n-micro/utils/normalize`, `@i18n-micro/utils/parse-path`, `@i18n-micro/utils/payload-config`, `@i18n-micro/utils/payload-fetch`, `@i18n-micro/utils/payload-stats`, `@i18n-micro/utils/payload-url`, `@i18n-micro/utils/resolve-hreflang`, `@i18n-micro/utils/resolve-locale`, `@i18n-micro/utils/resolve-og-locale`, `@i18n-micro/utils/route`, `@i18n-micro/utils/route-pattern`, `@i18n-micro/utils/runtime-config`, `@i18n-micro/utils/source-loader` | 98 |
3434
| [`@i18n-micro/vue`](/api/packages/vue) | `@i18n-micro/vue` | 27 |
3535

3636
<!-- /generated:packages-index -->

docs/api/packages/utils.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ outline: 'deep'
66

77
# `@i18n-micro/utils`
88

9-
97 exports across 22 entry points.
9+
98 exports across 23 entry points.
1010
Generated from the API snapshot that [`pnpm run api:surface`](/guide/maintenance-commands#api-surface)
1111
checks against the TypeScript sources.
1212

@@ -44,6 +44,16 @@ import { /* … */ } from '@i18n-micro/utils/app-path'
4444
| --- | --- | --- |
4545
| `withoutAppBaseURL` | function | `(pathname: string, baseURL?: string \| null) => string` |
4646

47+
## `@i18n-micro/utils/auto-detect-path`
48+
49+
```ts
50+
import { /**/ } from '@i18n-micro/utils/auto-detect-path'
51+
```
52+
53+
| Export | Kind | Signature |
54+
| --- | --- | --- |
55+
| `shouldAttemptLocaleRedirect` | function | `(path: string, options?: { autoDetectPath?: string; hasLocalePrefix?: boolean; }) => boolean` |
56+
4757
## `@i18n-micro/utils/build`
4858

4959
```ts

docs/guide/configuration.md

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The sections below explain how they work together; the
2121
| [`locales`](/api/module-options) | `Locale[]` | `[]` | List of supported locales. |
2222
| [`meta`](/api/module-options) | `boolean` | `true` | Generate SEO meta tags (`hreflang`, `canonical`, `og:url`, `og:locale`) automatically. |
2323
| [`strategy`](/api/module-options) | `Strategies` | `'prefix_except_default'` | URL routing strategy for locale prefixes. - `'no_prefix'` — no locale in URL; locale stored in cookie. - `'prefix_except_default'` — prefix all locales except the default. - `'prefix'` — always prefix, including the default locale. - `'prefix_and_default'` — like `prefix`, but the default locale is also accessible without prefix. |
24-
| [`metaBaseUrl`](/api/module-options) | `string` | `undefined` | Base URL for SEO meta tags (canonical, og:url, hreflang). - `undefined`dynamically resolved from the current request URL (`useRequestURL().origin` on server, `window.location.origin` on client). |
24+
| [`metaBaseUrl`](/api/module-options) | `string` | `undefined` | Base URL for SEO meta tags (canonical, og:url, hreflang). - A concrete URL string (e.g. `'https://example.com'`) — used as-is (highest priority). - `undefined`falls back to `site.url` from `nuxt-site-config` when that module is present, otherwise the current request origin (`useRequestURL().origin` on server, `window.location.origin` on client). |
2525
| [`metaTrustForwardedHost`](/api/module-options) | `boolean` | `true` | Trust the `X-Forwarded-Host` header when resolving the base URL for meta tags. |
2626
| [`metaTrustForwardedProto`](/api/module-options) | `boolean` | `true` | Trust the `X-Forwarded-Proto` header when resolving the protocol for meta tags. |
2727
| [`hreflangBaseLanguage`](/api/module-options) | `boolean` | `false` | Also emit a bare-language `hreflang` derived from each locale's `iso` (e.g. `es-ES` → also `es`). |
@@ -366,22 +366,25 @@ meta: true // Generate alternate links, canonical URLs, etc.
366366

367367
Base URL for SEO meta tags (canonical, og:url, hreflang).
368368

369-
- `undefined` — dynamically resolved from the current request URL
369+
- A concrete URL string (e.g. `'https://example.com'`) — used as-is (highest priority).
370+
- `undefined` — falls back to `site.url` from `nuxt-site-config` when that module is
371+
present, otherwise the current request origin
370372
(`useRequestURL().origin` on server, `window.location.origin` on client).
371-
Best for multi-domain deployments.
372-
373-
- A concrete URL string (e.g. `'https://example.com'`) — used as-is.
374373

375374
<!-- /generated:option:metaBaseUrl -->
376375

377-
- `undefined` (or omitted) — the base URL is resolved dynamically from the incoming request on the server (`useRequestURL().origin`, respects `X-Forwarded-Host` / `X-Forwarded-Proto` proxy headers) and from `window.location.origin` on the client. Ideal for **multi-domain** deployments where the same application serves multiple hostnames.
378-
- Any other string — used as a static base URL.
376+
Priority when unset: `site.url` from `nuxt-site-config` (if installed) → request origin
377+
(`useRequestURL().origin` on the server with `X-Forwarded-Host` / `X-Forwarded-Proto`,
378+
`window.location.origin` on the client). Ideal for **SEO stacks** that already set `site.url`,
379+
and for **multi-domain** deployments when `site.url` is omitted.
380+
381+
- Any other string — used as a static base URL (wins over `site.url`).
379382

380383
```typescript
381-
// Dynamic — automatically uses the current request hostname (recommended for multi-domain)
382-
// Simply omit metaBaseUrl or set it to undefined
384+
// Prefer sharing the SEO stack origin — omit metaBaseUrl when site.url is set
385+
site: { url: 'https://example.com' }
383386

384-
// Static — always uses the specified URL
387+
// Or pin an explicit override
385388
metaBaseUrl: 'https://example.com'
386389
```
387390
#### `canonicalQueryWhitelist`

docs/guide/seo.md

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,28 @@ export default defineNuxtConfig({
100100

101101
### 🌍 Dynamic `metaBaseUrl` for Multi-Domain Deployments
102102

103-
By default (`metaBaseUrl` is `undefined`), canonical URLs, `og:url`, and `hreflang` links are generated using the hostname from the current request. This is resolved via `useRequestURL()` on the server and `window.location.origin` on the client.
103+
When `metaBaseUrl` is unset, absolute SEO URLs resolve in this order (#240):
104104

105-
The module respects reverse-proxy headers (`X-Forwarded-Host`, `X-Forwarded-Proto`), so it works correctly behind nginx, Cloudflare, AWS ALB, and similar proxies.
105+
1. `site.url` from [`nuxt-site-config`](https://nuxtseo.com/docs/site-config) (if that module is present — e.g. via `@nuxtjs/seo`)
106+
2. Otherwise the hostname from the current request (`useRequestURL()` / `window.location.origin`)
106107

107-
This means a single application instance can serve **multiple domains** with correct SEO tags for each:
108+
The request-origin fallback respects reverse-proxy headers (`X-Forwarded-Host`, `X-Forwarded-Proto`), so it works correctly behind nginx, Cloudflare, AWS ALB, and similar proxies.
109+
110+
That means apps already setting `site.url` for sitemap / robots / schema.org do **not** need a second `metaBaseUrl` declaration:
111+
112+
```typescript
113+
export default defineNuxtConfig({
114+
site: {
115+
url: process.env.NUXT_SITE_URL, // also used by micro for canonical / og:url / hreflang
116+
},
117+
i18n: {
118+
meta: true,
119+
// metaBaseUrl omitted — picks up site.url, then request origin
120+
},
121+
})
122+
```
123+
124+
Without `site.url`, a single application instance can still serve **multiple domains** with correct SEO tags for each via the request origin:
108125

109126
```typescript
110127
export default defineNuxtConfig({
@@ -127,7 +144,7 @@ While the same app serving `https://site-b.com/en/about` will produce:
127144
<link rel="canonical" href="https://site-b.com/en/about" /> <meta property="og:url" content="https://site-b.com/en/about" />
128145
```
129146

130-
If you need a fixed base URL instead, pass a static string:
147+
If you need a fixed base URL that always wins over `site.url`, pass a static string:
131148

132149
```typescript
133150
metaBaseUrl: 'https://example.com'

packages/types/src/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,10 @@ export interface ModuleOptions {
179179

180180
/**
181181
* Base URL for SEO meta tags (canonical, og:url, hreflang).
182-
* - `undefined` — dynamically resolved from the current request URL
182+
* - A concrete URL string (e.g. `'https://example.com'`) — used as-is (highest priority).
183+
* - `undefined` — falls back to `site.url` from `nuxt-site-config` when that module is
184+
* present, otherwise the current request origin
183185
* (`useRequestURL().origin` on server, `window.location.origin` on client).
184-
* Best for multi-domain deployments.
185-
* - A concrete URL string (e.g. `'https://example.com'`) — used as-is.
186186
* @default undefined
187187
*/
188188
metaBaseUrl?: string

src/runtime/plugins/02.meta.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ import { useI18nHead } from '../composables/useI18nHead'
99
import { useLocaleHead } from '../composables/useLocaleHead'
1010
import type { PluginsInjections } from './01.plugin'
1111

12+
function readSiteConfigUrl(nuxtApp: { $nuxtSiteConfig?: { get?: (opts?: object) => { url?: unknown } } }): string | undefined {
13+
// Soft probe: only present when nuxt-site-config (via @nuxtjs/seo etc.) is installed.
14+
const stack = nuxtApp.$nuxtSiteConfig
15+
if (!stack || typeof stack.get !== 'function') return undefined
16+
try {
17+
const url = stack.get({ resolveRefs: true })?.url
18+
return typeof url === 'string' && url.trim() ? url : undefined
19+
} catch {
20+
return undefined
21+
}
22+
}
23+
1224
export default defineNuxtPlugin((nuxtApp) => {
1325
const route = useRoute()
1426
const i18nRouteParams = useState<I18nRouteParams>('i18n-route-params', () => ({}))
@@ -25,16 +37,17 @@ export default defineNuxtPlugin((nuxtApp) => {
2537
return
2638
}
2739

28-
// Resolve base URL for SEO meta tags.
29-
// undefined → dynamically resolve from the current request (supports multi-domain).
40+
// Resolve base URL for SEO meta tags (#240):
41+
// metaBaseUrl (explicit) → site.url (nuxt-site-config) → request origin
3042
// Proxy-header options so the origin is correct behind nginx / Cloudflare / ALB / etc.:
3143
// X-Forwarded-Host → real hostname (controlled by metaTrustForwardedHost)
3244
// X-Forwarded-Proto → real protocol (controlled by metaTrustForwardedProto)
3345
const url = useRequestURL({
3446
xForwardedHost: i18nConfig.metaTrustForwardedHost !== false,
3547
xForwardedProto: i18nConfig.metaTrustForwardedProto !== false,
3648
})
37-
const baseUrl = i18nConfig.metaBaseUrl || url.origin
49+
const siteUrl = readSiteConfigUrl(nuxtApp as { $nuxtSiteConfig?: { get?: (opts?: object) => { url?: unknown } } })
50+
const baseUrl = (i18nConfig.metaBaseUrl || siteUrl || url.origin).replace(/\/+$/, '')
3851

3952
const { metaObject, updateMeta } = useLocaleHead({
4053
addDirAttribute: true,

test/nuxt-seo.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,17 @@ describe('@nuxtjs/seo integration (#133)', () => {
103103
expect(deHtml).toMatch(/<meta[^>]*name="description"[^>]*content="Deutsche Seitenbeschreibung"/)
104104
})
105105

106+
test('SSR: canonical and og:url use site.url when metaBaseUrl is unset (#240)', async ({ request }) => {
107+
const html = await (await request.get('/en/about')).text()
108+
109+
expect(html).toMatch(/<link[^>]*rel="canonical"[^>]*href="https:\/\/example\.com\/en\/about"/)
110+
expect(html).toMatch(/<meta[^>]*property="og:url"[^>]*content="https:\/\/example\.com\/en\/about"/)
111+
112+
const alternates = extractHreflangLinks(html)
113+
expect(alternates.some((link) => link.hreflang === 'en-US' && link.href.startsWith('https://example.com/'))).toBe(true)
114+
expect(alternates.some((link) => link.hreflang === 'de-DE' && link.href.startsWith('https://example.com/'))).toBe(true)
115+
})
116+
106117
test('SSR: canonical and og:url tags are present on localized pages', async ({ request }) => {
107118
const html = await (await request.get('/en/about')).text()
108119

0 commit comments

Comments
 (0)