Validate
The Validate plugin reports non-absolute URLs, missing Open Graph companions, likely meta-property typos, conflicting robots directives, and other head mistakes. It runs only when registered.
Runtime validation
The Validate plugin inspects the final resolved head output and warns about issues that TypeScript can't catch:
- URL problems: Relative canonical or Open Graph URLs, and canonical versus
og:urlmismatches - Missing tags: No title, no description on an indexable page, or missing Open Graph companions
- Content issues: Empty meta content, HTML in a title, or unresolved template params
- Conflicts: Contradictory robots directives or accessibility-harmful viewport settings
- Typos: Unknown meta properties or names, with fuzzy-matched suggestions
Setup
Register the plugin where you want head tag validation. It is omitted from builds that do not import it:
import { ValidatePlugin } from 'unhead/plugins'
const head = createHead({
plugins: [
ValidatePlugin()
]
})
By default, warnings are logged via console.warn. You can provide a custom reporter:
ValidatePlugin({
onReport(rules) {
// rules: Array<{ id, message, severity, source?, tag? }>
for (const rule of rules) {
const loc = rule.source ? ` (${rule.source})` : ''
console.warn(`[${rule.id}] ${rule.message}${loc}`)
}
}
})
Options
export interface ValidatePluginOptions {
/**
* Callback to handle validation results. Receives all rules found per resolve cycle.
* Defaults to `console.warn` for each rule.
*/
onReport?: (rules: HeadValidationRule[]) => void
/** Configure severity and per-rule options. */
rules?: RulesConfig
/**
* Project root path. When set, source locations are displayed as relative paths (e.g., ./src/components/MyPage.vue:42:5).
*/
root?: string
}
Rules
URL Validity
Google recommends absolute canonical URLs, and the Open Graph URL type is limited to http:// and https:// resources.
| Rule ID | What it catches |
|---|---|
non-absolute-canonical | Canonical URL is not absolute (/page instead of https://example.com/page) |
non-absolute-og-url | og:image, og:url, og:video, og:audio, twitter:image, etc. are not absolute URLs |
canonical-og-url-mismatch | <link rel="canonical"> href differs from og:url content |
Content Quality
| Rule ID | What it catches |
|---|---|
missing-title | Page has no <title> tag |
missing-description | Page has no <meta name="description"> and is indexable (no noindex) |
empty-title | Title tag exists but is empty or whitespace-only |
empty-meta-content | Meta tag has name/property but empty content |
html-in-title | Title contains < or > characters (will be escaped, not rendered as HTML) |
unresolved-template-param | Literal %paramName% found in rendered output; template params may be misconfigured |
Migration and Configuration
| Rule ID | What it catches |
|---|---|
deprecated-option-mode | Removed v2 mode entry option |
deprecated-prop-body | Removed body: true tag property |
deprecated-prop-children | Removed children content property |
deprecated-prop-hid-vmid | Removed hid or vmid keys |
deprecated-twitter-meta | Non-Slack twitter:* metadata; use Open Graph metadata instead |
missing-alias-sorting-plugin | A before: or after: priority is used without AliasSortingPlugin |
missing-template-params-plugin | templateParams is used without TemplateParamsPlugin |
invalid-input-shape | A head field has the wrong container shape, or an attribute object contains a misplaced structured value |
numeric-tag-priority | A numeric tagPriority is used instead of a named priority |
twitter:label1, twitter:label2, twitter:data1, and twitter:data2 remain supported for Slack link unfurls.
Input shapes are checked after framework resolvers run. Vue refs, computed values, and computed getters are validated using their resolved values.
Missing Companion Tags
The Open Graph protocol defines the basic object fields and optional structured properties for og:image. og:description and image dimensions are optional in the protocol; the plugin recommends them for more complete link previews but does not treat them as conformance errors.
| Rule ID | What it catches |
|---|---|
og-image-missing-dimensions | og:image is set without its optional og:image:width and/or og:image:height structured properties |
og-missing-title | Open Graph tags are present but og:title is missing |
og-missing-description | Open Graph tags are present but og:description is missing |
preload-font-crossorigin | <link rel="preload" as="font"> is missing crossorigin, so its CORS mode may not match the eventual font request |
preload-missing-as | <link rel="preload"> is missing the required as attribute |
script-src-with-content | <script src="..."> also has inline content; the browser will ignore the inline content |
Conflict Detection
| Rule ID | Severity | What it catches |
|---|---|---|
robots-conflict | warn | Robots meta has contradictory directives (index, noindex or follow, nofollow) |
viewport-user-scalable | info | Viewport has user-scalable=no or maximum-scale=1, which harms accessibility |
twitter-handle-missing-at | warn | twitter:site or twitter:creator value does not start with @ |
Typo Detection
| Rule ID | What it catches |
|---|---|
possible-typo | Unknown meta property or name close to a known value; suggests og:title for og:titl |
Typo detection only runs for recognized prefixes (og:, article:, book:, profile:, fb:, twitter:, or standard meta names without a colon). Custom prefixes like custom:foo are ignored.
Performance Hints
These rules encode conservative heuristics rather than universal browser limits. Their underlying browser behavior is documented in the primary references for script loading, preload and CORS request matching, resource hints, early charset declarations, and viewport accessibility.
| Rule ID | Severity | What it catches |
|---|---|---|
render-blocking-script | warn | <script src> in head without async, defer, or type="module" pauses the HTML parser |
too-many-fetchpriority-high | warn | More than two resources have fetchpriority="high", which can dilute the priority signal |
defer-on-module-script | info | defer on a type="module" script is redundant. Modules are deferred by default |
duplicate-resource-hint | warn | Same rel/href pair appears multiple times in preload, prefetch, or preconnect tags |
charset-not-early | warn | During SSR, <meta charset> appears after the configured number of rendered head tags (three by default). This is a tag-position heuristic; it does not measure the declaration's byte offset |
preload-not-modulepreload | warn | <link rel="preload" as="script"> for a module script should use rel="modulepreload" to also trigger module parsing |
preconnect-missing-crossorigin | warn | <link rel="preconnect"> is missing crossorigin but CORS resources are loaded from that origin, causing a separate connection |
preload-fetchpriority-conflict | warn | A non-script preload has fetchpriority="low"; script preloads are exempt because useScript() uses that combination for warmup |
too-many-preloads | warn | More than 6 <link rel="preload"> tags compete for bandwidth and hurt performance |
too-many-prefetches | info | More than 50 <link rel="prefetch"> tags may consume speculative bandwidth and cache capacity. This advisory guardrail is not a browser or standards limit |
too-many-preconnects | warn | More than 4 <link rel="preconnect"> tags; each starts connection work that can compete with critical resources |
redundant-dns-prefetch | info | Same origin has both <link rel="preconnect"> and <link rel="dns-prefetch">; preconnect already includes DNS resolution |
preload-async-defer-conflict | warn | A preloaded script also has async or defer and the preload is not marked fetchpriority="low". Browsers allow this combination; the warning is the plugin's priority heuristic |
prefetch-preload-conflict | warn | Same resource has both preload and prefetch; use preload for current page, prefetch for future navigation |
inline-style-size | info | Inline <style> exceeds the plugin's default 14KB threshold |
inline-script-size | info | Inline <script> exceeds 2KB; consider moving to an external file for cacheability |
meta-beyond-1mb | warn | The plugin's serialized-size estimate places a <meta> tag beyond its default 1MB inspection threshold |
Rule configuration
Rules can be disabled or have their severity overridden, similar to ESLint's flat config:
ValidatePlugin({
rules: {
'missing-description': 'off',
'viewport-user-scalable': 'off',
'missing-title': 'info', // downgrade from warn to info
}
})
Some rules accept an options object as an ESLint-style [severity, options] tuple:
ValidatePlugin({
rules: {
'too-many-preloads': ['warn', { max: 10 }],
'too-many-prefetches': ['info', { max: 100 }],
'too-many-preconnects': ['warn', { max: 6 }],
'too-many-fetchpriority-high': ['warn', { max: 3 }],
'charset-not-early': ['warn', { maxPosition: 5 }],
'inline-style-size': ['info', { maxKB: 20 }],
'inline-script-size': ['info', { maxKB: 5 }],
'meta-beyond-1mb': ['warn', { maxBytes: 512_000 }], // 500KB instead of default 1MB
}
})
Only rules that support options accept the tuple form, and each rule's options are typed.
Source tracing
Rules associated with a specific tag can include a source field pointing to the head.push() call that introduced it. Cross-tag rules such as a missing title may not have a source. By default, captured sources use absolute paths. Set root to make them relative:
ValidatePlugin({
root: process.cwd(),
})
// output: [unhead] Canonical URL should be absolute, received "/page". (./src/components/MyPage.vue:42:5)
Framework DevTools integration
The onReport callback receives structured rule objects that you can pass to a UI:
ValidatePlugin({
onReport(rules) {
// Example: Nuxt DevTools integration
for (const rule of rules) {
devtools.addWarning({
id: rule.id,
message: rule.message,
severity: rule.severity,
// rule.tag contains the full HeadTag object for inspection
})
}
}
})
Related
- Canonical Plugin: Auto-resolve relative URLs to absolute
- Infer SEO Meta: Auto-generate OG and Twitter meta tags
- useSeoMeta(): Type-safe SEO meta management