TypeScript
Plugins

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:url mismatches
  • 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:

Input
import { ValidatePlugin } from 'unhead/plugins'

const head = createHead({
  plugins: [
    ValidatePlugin()
  ]
})

By default, warnings are logged via console.warn. You can provide a custom reporter:

Input
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

Input
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 IDWhat it catches
non-absolute-canonicalCanonical URL is not absolute (/page instead of https://example.com/page)
non-absolute-og-urlog: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 IDWhat it catches
missing-titlePage has no <title> tag
missing-descriptionPage has no <meta name="description"> and is indexable (no noindex)
empty-titleTitle tag exists but is empty or whitespace-only
empty-meta-contentMeta tag has name/property but empty content
html-in-titleTitle contains < or > characters (will be escaped, not rendered as HTML)
unresolved-template-paramLiteral %paramName% found in rendered output; template params may be misconfigured

Migration and Configuration

Rule IDWhat it catches
deprecated-option-modeRemoved v2 mode entry option
deprecated-prop-bodyRemoved body: true tag property
deprecated-prop-childrenRemoved children content property
deprecated-prop-hid-vmidRemoved hid or vmid keys
deprecated-twitter-metaNon-Slack twitter:* metadata; use Open Graph metadata instead
missing-alias-sorting-pluginA before: or after: priority is used without AliasSortingPlugin
missing-template-params-plugintemplateParams is used without TemplateParamsPlugin
invalid-input-shapeA head field has the wrong container shape, or an attribute object contains a misplaced structured value
numeric-tag-priorityA 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 IDWhat it catches
og-image-missing-dimensionsog:image is set without its optional og:image:width and/or og:image:height structured properties
og-missing-titleOpen Graph tags are present but og:title is missing
og-missing-descriptionOpen 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 IDSeverityWhat it catches
robots-conflictwarnRobots meta has contradictory directives (index, noindex or follow, nofollow)
viewport-user-scalableinfoViewport has user-scalable=no or maximum-scale=1, which harms accessibility
twitter-handle-missing-atwarntwitter:site or twitter:creator value does not start with @

Typo Detection

Rule IDWhat it catches
possible-typoUnknown 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 IDSeverityWhat it catches
render-blocking-scriptwarn<script src> in head without async, defer, or type="module" pauses the HTML parser
too-many-fetchpriority-highwarnMore than two resources have fetchpriority="high", which can dilute the priority signal
defer-on-module-scriptinfodefer on a type="module" script is redundant. Modules are deferred by default
duplicate-resource-hintwarnSame rel/href pair appears multiple times in preload, prefetch, or preconnect tags
charset-not-earlywarnDuring 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-modulepreloadwarn<link rel="preload" as="script"> for a module script should use rel="modulepreload" to also trigger module parsing
preconnect-missing-crossoriginwarn<link rel="preconnect"> is missing crossorigin but CORS resources are loaded from that origin, causing a separate connection
preload-fetchpriority-conflictwarnA non-script preload has fetchpriority="low"; script preloads are exempt because useScript() uses that combination for warmup
too-many-preloadswarnMore than 6 <link rel="preload"> tags compete for bandwidth and hurt performance
too-many-prefetchesinfoMore 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-preconnectswarnMore than 4 <link rel="preconnect"> tags; each starts connection work that can compete with critical resources
redundant-dns-prefetchinfoSame origin has both <link rel="preconnect"> and <link rel="dns-prefetch">; preconnect already includes DNS resolution
preload-async-defer-conflictwarnA 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-conflictwarnSame resource has both preload and prefetch; use preload for current page, prefetch for future navigation
inline-style-sizeinfoInline <style> exceeds the plugin's default 14KB threshold
inline-script-sizeinfoInline <script> exceeds 2KB; consider moving to an external file for cacheability
meta-beyond-1mbwarnThe 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:

Input
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:

Input
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:

Input
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:

Input
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
      })
    }
  }
})
Did this page help you?