Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 8369d8f

Browse files
harlan-zwclaude
andauthored
feat(canonical): query param filtering, trailing slash & hash stripping (#713)
* feat(canonical): add query parameter filtering to CanonicalPlugin Strips non-content-affecting query params (utm_source, fbclid, gclid, etc.) from canonical and og:url tags to prevent duplicate content issues. Defaults to whitelisting page, sort, filter, search, q, category, tag. Configurable via queryWhitelist option. Also adds framework setup guides for Nuxt, Vue, React, Svelte, and Angular. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(canonical): add trailing slash normalization and hash stripping Adds trailingSlash option (true/false/undefined) to enforce consistent trailing slashes on canonical and og:url tags. Also strips hash fragments from canonical URLs since search engines ignore them. Documents both features. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(canonical): resolve gaps in URL coverage and normalization - Remove non-standard `og:see_also` from transformable URLs - Add missing `og:audio`, `og:audio:url`, `og:audio:secure_url`, `og:image:url`, `og:video:url`, `twitter:player`, `twitter:player:stream` - Resolve `rel="next"` and `rel="prev"` link hrefs to absolute URLs - Ensure normalization (query filtering, hash stripping, trailing slash) runs after `customResolver` so it's never bypassed - Export `DEFAULT_QUERY_WHITELIST` from `unhead/plugins` for extensibility Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(canonical): resolve alternate, author, license, help, search, pingback links Add rel="alternate", "author", "license", "help", "search", and "pingback" to resolvable link rels. Most importantly, hreflang alternate links now get resolved to absolute URLs which is critical for international SEO. Canonical normalization (query filtering, hash stripping) only applies to rel="canonical", not to these other rels. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * docs(canonical): update tag list with all resolved meta and link tags Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore(canonical): export CanonicalPluginOptions type Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(canonical): address CodeRabbit review feedback - Fix Vue docs: use `@unhead/vue/client` import path for createHead - Fix React docs: use `@unhead/react/client` import path for createHead - Fix Svelte docs: use createHead() in entry-client.ts instead of useUnhead() which requires component context - Fix normalizeCanonicalUrl: pass `host` as base to `new URL()` so protocol-relative URLs (//example.com/...) are properly normalized Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * test: update export snapshots for DEFAULT_QUERY_WHITELIST Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(canonical): strip all query params by default, make whitelist opt-in Since the plugin is already opt-in, users should explicitly declare which query params to preserve rather than relying on a default whitelist. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 21a655a commit 8369d8f

4 files changed

Lines changed: 779 additions & 7 deletions

File tree

docs/head/1.guides/plugins/canonical.md

Lines changed: 202 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,32 @@ The Canonical Plugin automatically converts relative URLs to absolute URLs in yo
1616

1717
## What Tags Does the Plugin Transform?
1818

19-
The plugin transforms these tags automatically:
19+
The plugin resolves relative URLs to absolute URLs in all of these tags:
2020

21-
- `og:image` and `twitter:image` meta tags
22-
- `og:url` meta tag
23-
- `rel="canonical"` link tag
21+
### Meta Tags
22+
23+
- `og:url`, `og:image`, `og:image:url`, `og:image:secure_url`
24+
- `og:video`, `og:video:url`, `og:video:secure_url`
25+
- `og:audio`, `og:audio:url`, `og:audio:secure_url`
26+
- `twitter:image`, `twitter:image:src`
27+
- `twitter:player`, `twitter:player:stream`
28+
29+
### Link Tags
30+
31+
- `rel="canonical"` — with query filtering, hash stripping, and trailing slash normalization
32+
- `rel="next"` / `rel="prev"` — pagination links
33+
- `rel="alternate"` — hreflang and feed links (critical for international SEO)
34+
- `rel="author"`, `rel="license"`, `rel="help"`, `rel="search"`, `rel="pingback"`
2435

2536
::code-block
2637
```html [Before]
2738
<meta property="og:image" content="/images/hero.jpg">
39+
<link rel="alternate" hreflang="es" href="/es/page">
2840
```
2941

3042
```html [After]
3143
<meta property="og:image" content="https://mysite.com/images/hero.jpg">
44+
<link rel="alternate" hreflang="es" href="https://mysite.com/es/page">
3245
```
3346
::
3447

@@ -50,6 +63,87 @@ const head = createHead({
5063
```
5164
::
5265

66+
## How Does Query Parameter Filtering Work?
67+
68+
Tracking parameters like `utm_source`, `fbclid`, and `gclid` create duplicate URLs that dilute your SEO. The plugin automatically strips **all** query parameters from canonical and `og:url` tags by default.
69+
70+
::code-block
71+
```html [Before]
72+
<link rel="canonical" href="https://mysite.com/blog?page=2&utm_source=twitter&fbclid=abc">
73+
```
74+
75+
```html [After]
76+
<link rel="canonical" href="https://mysite.com/blog">
77+
```
78+
::
79+
80+
### How Do I Preserve Specific Query Parameters?
81+
82+
If your site uses query parameters that affect content (e.g. pagination, filters), pass them via `queryWhitelist`:
83+
84+
::code-block
85+
```ts [Input]
86+
CanonicalPlugin({
87+
canonicalHost: 'https://mysite.com',
88+
queryWhitelist: ['page', 'sort', 'q', 'category']
89+
})
90+
```
91+
::
92+
93+
Common parameters you may want to whitelist:
94+
95+
- `page` - Pagination
96+
- `sort` - Sort order
97+
- `filter` - Content filters
98+
- `search`, `q` - Search queries
99+
- `category`, `tag` - Category/tag filters
100+
- `lang`, `locale` - Language variants
101+
102+
### How Do I Disable Query Filtering?
103+
104+
Set `queryWhitelist` to `false` to keep all query parameters:
105+
106+
::code-block
107+
```ts [Input]
108+
CanonicalPlugin({
109+
canonicalHost: 'https://mysite.com',
110+
queryWhitelist: false
111+
})
112+
```
113+
::
114+
115+
::tip
116+
Query filtering only applies to `rel="canonical"` and `og:url` tags. Image and video URLs (`og:image`, `twitter:image`, etc.) are never filtered, since their query parameters often control dimensions and formats.
117+
::
118+
119+
## How Does Trailing Slash Normalization Work?
120+
121+
Inconsistent trailing slashes (`/about` vs `/about/`) create duplicate canonical URLs. Use the `trailingSlash` option to enforce consistency:
122+
123+
::code-block
124+
```ts [Input]
125+
// Always add trailing slash
126+
CanonicalPlugin({
127+
canonicalHost: 'https://mysite.com',
128+
trailingSlash: true
129+
})
130+
131+
// Always remove trailing slash
132+
CanonicalPlugin({
133+
canonicalHost: 'https://mysite.com',
134+
trailingSlash: false
135+
})
136+
```
137+
::
138+
139+
::tip
140+
The root path `/` is never stripped of its trailing slash, even when `trailingSlash` is `false`.
141+
::
142+
143+
## Does the Plugin Strip URL Fragments?
144+
145+
Yes. Hash fragments (e.g. `#section`) are automatically removed from canonical and `og:url` tags. Search engines ignore fragments, and leaving them in can create unnecessary URL variations.
146+
53147
## What Are the Configuration Options?
54148

55149
::code-block
@@ -59,6 +153,11 @@ interface CanonicalPluginOptions {
59153
canonicalHost?: string
60154
// Optional: Custom function to transform URLs
61155
customResolver?: (path: string) => string
156+
// Query parameters to preserve (default: [] — strips all)
157+
// Set to false to disable filtering
158+
queryWhitelist?: string[] | false
159+
// Normalize trailing slashes (true = add, false = remove, undefined = leave as-is)
160+
trailingSlash?: boolean
62161
}
63162
```
64163
::
@@ -116,6 +215,105 @@ CanonicalPlugin({
116215
```
117216
::
118217

218+
## Framework Setup Guides
219+
220+
### Nuxt
221+
222+
Nuxt has built-in Unhead support. Register the plugin in a [Nuxt plugin](https://nuxt.com/docs/guide/directory-structure/plugins):
223+
224+
::code-block
225+
```ts [plugins/canonical.ts]
226+
import { injectHead } from '@unhead/vue'
227+
import { CanonicalPlugin } from 'unhead/plugins'
228+
229+
export default defineNuxtPlugin(() => {
230+
const head = injectHead()
231+
head.use(CanonicalPlugin({
232+
canonicalHost: 'https://mysite.com'
233+
}))
234+
})
235+
```
236+
::
237+
238+
### Vue
239+
240+
Register the plugin when creating your head instance:
241+
242+
::code-block
243+
```ts [main.ts]
244+
import { createHead } from '@unhead/vue/client'
245+
import { CanonicalPlugin } from 'unhead/plugins'
246+
247+
const head = createHead({
248+
plugins: [
249+
CanonicalPlugin({
250+
canonicalHost: 'https://mysite.com'
251+
})
252+
]
253+
})
254+
255+
app.use(head)
256+
```
257+
::
258+
259+
### React
260+
261+
Register the plugin in your app entry:
262+
263+
::code-block
264+
```tsx [app.tsx]
265+
import { createHead } from '@unhead/react/client'
266+
import { CanonicalPlugin } from 'unhead/plugins'
267+
268+
const head = createHead({
269+
plugins: [
270+
CanonicalPlugin({
271+
canonicalHost: 'https://mysite.com'
272+
})
273+
]
274+
})
275+
```
276+
::
277+
278+
### Svelte
279+
280+
Register the plugin when creating the head instance in your entry file:
281+
282+
::code-block
283+
```ts [src/entry-client.ts]
284+
import { createHead, UnheadContextKey } from '@unhead/svelte/client'
285+
import { CanonicalPlugin } from 'unhead/plugins'
286+
287+
const head = createHead()
288+
head.use(CanonicalPlugin({
289+
canonicalHost: 'https://mysite.com'
290+
}))
291+
```
292+
::
293+
294+
### Angular
295+
296+
Register the plugin via `provideClientHead` options:
297+
298+
::code-block
299+
```ts [app.config.ts]
300+
import { provideClientHead } from '@unhead/angular'
301+
import { CanonicalPlugin } from 'unhead/plugins'
302+
303+
export const appConfig: ApplicationConfig = {
304+
providers: [
305+
provideClientHead({
306+
plugins: [
307+
CanonicalPlugin({
308+
canonicalHost: 'https://mysite.com'
309+
})
310+
]
311+
})
312+
]
313+
}
314+
```
315+
::
316+
119317
## Related
120318

121319
- [Template Params](/docs/head/guides/plugins/template-params) - Dynamic template parameters

packages/unhead/src/plugins/canonical.ts

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,72 @@ import type { HeadPluginOptions, Unhead } from '../types'
33
export interface CanonicalPluginOptions {
44
canonicalHost?: string
55
customResolver?: (url: string) => string
6+
/**
7+
* Query parameters to preserve in canonical and og:url tags.
8+
* All other query parameters will be stripped by default.
9+
*
10+
* Set to `false` to disable query filtering (keep all params).
11+
*
12+
* @default [] (strips all query params)
13+
*/
14+
queryWhitelist?: string[] | false
15+
/**
16+
* Whether canonical URLs should have a trailing slash.
17+
*
18+
* - `true` - always add trailing slash
19+
* - `false` - always remove trailing slash
20+
* - `undefined` - leave as-is (default)
21+
*/
22+
trailingSlash?: boolean
623
}
724

825
const META_TRANSFORMABLE_URL = [
926
'og:url',
1027
'og:image',
28+
'og:image:url',
1129
'og:image:secure_url',
1230
'twitter:image',
1331
'twitter:image:src',
1432
'og:video',
33+
'og:video:url',
1534
'og:video:secure_url',
16-
'og:see_also',
35+
'og:audio',
36+
'og:audio:url',
37+
'og:audio:secure_url',
38+
'twitter:player',
39+
'twitter:player:stream',
1740
]
1841

42+
/**
43+
* Link rel values that should have their href resolved to absolute URLs.
44+
*/
45+
const LINK_REL_RESOLVABLE = new Set([
46+
'canonical',
47+
'next',
48+
'prev',
49+
'alternate',
50+
'author',
51+
'license',
52+
'help',
53+
'search',
54+
'pingback',
55+
])
56+
57+
/**
58+
* Tags that represent page URLs and should have query params filtered,
59+
* hash fragments stripped, and trailing slash normalized.
60+
*/
61+
const META_CANONICAL_URL = new Set([
62+
'og:url',
63+
])
64+
1965
/**
2066
* CanonicalPlugin resolves paths in tags that require a canonical host to be set.
2167
*
2268
* - Resolves paths in meta tags like `og:image` and `twitter:image`.
2369
* - Resolves paths in the `og:url` meta tag.
2470
* - Resolves paths in the `link` tag with the `rel="canonical"` attribute.
71+
* - Filters query parameters from canonical and og:url tags.
2572
* @example
2673
* const plugin = CanonicalPlugin({
2774
* canonicalHost: 'https://example.com',
@@ -43,6 +90,43 @@ export function CanonicalPlugin(options: CanonicalPluginOptions): ((head: Unhead
4390
// have error thrown if canonicalHost is not a valid URL
4491
host = new URL(host).origin
4592

93+
const whitelist = options.queryWhitelist !== undefined ? options.queryWhitelist : []
94+
95+
function normalizeCanonicalUrl(url: string): string {
96+
try {
97+
const parsed = new URL(url, host)
98+
99+
// strip hash fragments - they're client-side only and ignored by search engines
100+
parsed.hash = ''
101+
102+
// filter query params
103+
if (whitelist !== false && parsed.search) {
104+
const filtered = new URLSearchParams()
105+
for (const key of whitelist) {
106+
if (parsed.searchParams.has(key)) {
107+
for (const value of parsed.searchParams.getAll(key)) {
108+
filtered.append(key, value)
109+
}
110+
}
111+
}
112+
parsed.search = filtered.toString()
113+
}
114+
115+
// normalize trailing slash
116+
if (options.trailingSlash === true && !parsed.pathname.endsWith('/')) {
117+
parsed.pathname = `${parsed.pathname}/`
118+
}
119+
else if (options.trailingSlash === false && parsed.pathname !== '/' && parsed.pathname.endsWith('/')) {
120+
parsed.pathname = parsed.pathname.slice(0, -1)
121+
}
122+
123+
return parsed.toString()
124+
}
125+
catch {
126+
return url
127+
}
128+
}
129+
46130
function resolvePath(path: string) {
47131
if (options?.customResolver) {
48132
return options.customResolver(path)
@@ -62,12 +146,20 @@ export function CanonicalPlugin(options: CanonicalPluginOptions): ((head: Unhead
62146
hooks: {
63147
'tags:resolve': (ctx) => {
64148
for (const tag of ctx.tags) {
149+
const metaKey = tag.props?.property || tag.props?.name
65150
// allow interchangable use of property and name for DX
66-
if (tag.tag === 'meta' && (META_TRANSFORMABLE_URL.includes(tag.props?.property) || META_TRANSFORMABLE_URL.includes(tag.props?.name))) {
151+
if (tag.tag === 'meta' && META_TRANSFORMABLE_URL.includes(metaKey)) {
67152
tag.props.content = resolvePath(tag.props.content)
153+
if (META_CANONICAL_URL.has(metaKey)) {
154+
tag.props.content = normalizeCanonicalUrl(tag.props.content)
155+
}
68156
}
69-
else if (tag.tag === 'link' && tag.props.rel === 'canonical') {
157+
else if (tag.tag === 'link' && LINK_REL_RESOLVABLE.has(tag.props.rel)) {
158+
const isCanonical = tag.props.rel === 'canonical'
70159
tag.props.href = resolvePath(tag.props.href)
160+
if (isCanonical) {
161+
tag.props.href = normalizeCanonicalUrl(tag.props.href)
162+
}
71163
}
72164
}
73165
},

packages/unhead/src/plugins/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { AliasSortingPlugin } from './aliasSorting'
22
export { CanonicalPlugin } from './canonical'
3+
export type { CanonicalPluginOptions } from './canonical'
34
export { defineHeadPlugin } from './defineHeadPlugin'
45
export { FlatMetaPlugin } from './flatMeta' // optional
56
export { InferSeoMetaPlugin } from './inferSeoMetaPlugin' // optional

0 commit comments

Comments
 (0)