---
title: "Validate"
description: "Catch common SEO, resource-loading, and head-tag mistakes. Validate URLs, metadata, Open Graph fields, and likely typos."
canonical_url: "https://unhead.unjs.io/docs/head/guides/plugins/validate"
last_updated: "2026-08-11T00:38:01.483Z"
---

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:

<code-block>

```ts [Input]
import { ValidatePlugin } from '@unhead/dynamic-import/plugins'

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

</code-block>

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

<code-block>

```ts [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}`)
    }
  }
})
```

</code-block>

## Options

<code-block>

```ts [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
}
```

</code-block>

## Rules

### URL Validity

Google recommends [absolute canonical URLs](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls#rel-canonical-link-method), and the [Open Graph URL type](https://ogp.me/#types) is limited to `http://` and `https://` resources.

<table>
<thead>
  <tr>
    <th>
      Rule ID
    </th>
    
    <th>
      What it catches
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        non-absolute-canonical
      </code>
    </td>
    
    <td>
      Canonical URL is not absolute (<code>
        /page
      </code>
      
       instead of <code>
        https://example.com/page
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        non-absolute-og-url
      </code>
    </td>
    
    <td>
      <code>
        og:image
      </code>
      
      , <code>
        og:url
      </code>
      
      , <code>
        og:video
      </code>
      
      , <code>
        og:audio
      </code>
      
      , <code>
        twitter:image
      </code>
      
      , etc. are not absolute URLs
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        canonical-og-url-mismatch
      </code>
    </td>
    
    <td>
      <code>
        <link rel="canonical">
      </code>
      
       href differs from <code>
        og:url
      </code>
      
       content
    </td>
  </tr>
</tbody>
</table>

### Content Quality

<table>
<thead>
  <tr>
    <th>
      Rule ID
    </th>
    
    <th>
      What it catches
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        missing-title
      </code>
    </td>
    
    <td>
      Page has no <code>
        <title>
      </code>
      
       tag
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        missing-description
      </code>
    </td>
    
    <td>
      Page has no <code>
        <meta name="description">
      </code>
      
       and is indexable (no <code>
        noindex
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        empty-title
      </code>
    </td>
    
    <td>
      Title tag exists but is empty or whitespace-only
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        empty-meta-content
      </code>
    </td>
    
    <td>
      Meta tag has <code>
        name
      </code>
      
      /<code>
        property
      </code>
      
       but empty <code>
        content
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        html-in-title
      </code>
    </td>
    
    <td>
      Title contains <code>
        <
      </code>
      
       or <code>
        >
      </code>
      
       characters (will be escaped, not rendered as HTML)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        unresolved-template-param
      </code>
    </td>
    
    <td>
      Literal <code>
        %paramName%
      </code>
      
       found in rendered output; template params may be misconfigured
    </td>
  </tr>
</tbody>
</table>

### Migration and Configuration

<table>
<thead>
  <tr>
    <th>
      Rule ID
    </th>
    
    <th>
      What it catches
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        deprecated-option-mode
      </code>
    </td>
    
    <td>
      Removed v2 <code>
        mode
      </code>
      
       entry option
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        deprecated-prop-body
      </code>
    </td>
    
    <td>
      Removed <code>
        body: true
      </code>
      
       tag property
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        deprecated-prop-children
      </code>
    </td>
    
    <td>
      Removed <code>
        children
      </code>
      
       content property
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        deprecated-prop-hid-vmid
      </code>
    </td>
    
    <td>
      Removed <code>
        hid
      </code>
      
       or <code>
        vmid
      </code>
      
       keys
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        deprecated-twitter-meta
      </code>
    </td>
    
    <td>
      Non-Slack <code>
        twitter:*
      </code>
      
       metadata; use Open Graph metadata instead
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        missing-alias-sorting-plugin
      </code>
    </td>
    
    <td>
      A <code>
        before:
      </code>
      
       or <code>
        after:
      </code>
      
       priority is used without <code>
        AliasSortingPlugin
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        missing-template-params-plugin
      </code>
    </td>
    
    <td>
      <code>
        templateParams
      </code>
      
       is used without <code>
        TemplateParamsPlugin
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        invalid-input-shape
      </code>
    </td>
    
    <td>
      A head field has the wrong container shape, or an attribute object contains a misplaced structured value
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        numeric-tag-priority
      </code>
    </td>
    
    <td>
      A numeric <code>
        tagPriority
      </code>
      
       is used instead of a named priority
    </td>
  </tr>
</tbody>
</table>

`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](https://ogp.me/) 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.

<table>
<thead>
  <tr>
    <th>
      Rule ID
    </th>
    
    <th>
      What it catches
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        og-image-missing-dimensions
      </code>
    </td>
    
    <td>
      <code>
        og:image
      </code>
      
       is set without its optional <code>
        og:image:width
      </code>
      
       and/or <code>
        og:image:height
      </code>
      
       structured properties
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        og-missing-title
      </code>
    </td>
    
    <td>
      Open Graph tags are present but <code>
        og:title
      </code>
      
       is missing
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        og-missing-description
      </code>
    </td>
    
    <td>
      Open Graph tags are present but <code>
        og:description
      </code>
      
       is missing
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        preload-font-crossorigin
      </code>
    </td>
    
    <td>
      <code>
        <link rel="preload" as="font">
      </code>
      
       is missing <code>
        crossorigin
      </code>
      
      , so its CORS mode may not match the eventual font request
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        preload-missing-as
      </code>
    </td>
    
    <td>
      <code>
        <link rel="preload">
      </code>
      
       is missing the required <code>
        as
      </code>
      
       attribute
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        script-src-with-content
      </code>
    </td>
    
    <td>
      <code>
        <script src="...">
      </code>
      
       also has inline content; the browser will ignore the inline content
    </td>
  </tr>
</tbody>
</table>

### Conflict Detection

<table>
<thead>
  <tr>
    <th>
      Rule ID
    </th>
    
    <th>
      Severity
    </th>
    
    <th>
      What it catches
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        robots-conflict
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      Robots meta has contradictory directives (<code>
        index, noindex
      </code>
      
       or <code>
        follow, nofollow
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        viewport-user-scalable
      </code>
    </td>
    
    <td>
      <code>
        info
      </code>
    </td>
    
    <td>
      Viewport has <code>
        user-scalable=no
      </code>
      
       or <code>
        maximum-scale=1
      </code>
      
      , which harms accessibility
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        twitter-handle-missing-at
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      <code>
        twitter:site
      </code>
      
       or <code>
        twitter:creator
      </code>
      
       value does not start with <code>
        @
      </code>
    </td>
  </tr>
</tbody>
</table>

### Typo Detection

<table>
<thead>
  <tr>
    <th>
      Rule ID
    </th>
    
    <th>
      What it catches
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        possible-typo
      </code>
    </td>
    
    <td>
      Unknown meta <code>
        property
      </code>
      
       or <code>
        name
      </code>
      
       close to a known value; suggests <code>
        og:title
      </code>
      
       for <code>
        og:titl
      </code>
    </td>
  </tr>
</tbody>
</table>

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](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script#notes), [preload and CORS request matching](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/rel/preload#cors-enabled_fetches), [resource hints](https://web.dev/learn/performance/resource-hints), [early charset declarations](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta#charset), and [viewport accessibility](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/viewport#usage_notes).

<table>
<thead>
  <tr>
    <th>
      Rule ID
    </th>
    
    <th>
      Severity
    </th>
    
    <th>
      What it catches
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        render-blocking-script
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      <code>
        <script src>
      </code>
      
       in head without <code>
        async
      </code>
      
      , <code>
        defer
      </code>
      
      , or <code>
        type="module"
      </code>
      
       pauses the HTML parser
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        too-many-fetchpriority-high
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      More than two resources have <code>
        fetchpriority="high"
      </code>
      
      , which can dilute the priority signal
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        defer-on-module-script
      </code>
    </td>
    
    <td>
      <code>
        info
      </code>
    </td>
    
    <td>
      <code>
        defer
      </code>
      
       on a <code>
        type="module"
      </code>
      
       script is redundant. Modules are deferred by default
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        duplicate-resource-hint
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      Same <code>
        rel
      </code>
      
      /<code>
        href
      </code>
      
       pair appears multiple times in preload, prefetch, or preconnect tags
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        charset-not-early
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      During SSR, <code>
        <meta charset>
      </code>
      
       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
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        preload-not-modulepreload
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      <code>
        <link rel="preload" as="script">
      </code>
      
       for a module script should use <code>
        rel="modulepreload"
      </code>
      
       to also trigger module parsing
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        preconnect-missing-crossorigin
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      <code>
        <link rel="preconnect">
      </code>
      
       is missing <code>
        crossorigin
      </code>
      
       but CORS resources are loaded from that origin, causing a separate connection
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        preload-fetchpriority-conflict
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      A non-script preload has <code>
        fetchpriority="low"
      </code>
      
      ; script preloads are exempt because <code>
        useScript()
      </code>
      
       uses that combination for warmup
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        too-many-preloads
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      More than 6 <code>
        <link rel="preload">
      </code>
      
       tags compete for bandwidth and hurt performance
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        too-many-prefetches
      </code>
    </td>
    
    <td>
      <code>
        info
      </code>
    </td>
    
    <td>
      More than 50 <code>
        <link rel="prefetch">
      </code>
      
       tags may consume speculative bandwidth and cache capacity. This advisory guardrail is not a browser or standards limit
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        too-many-preconnects
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      More than 4 <code>
        <link rel="preconnect">
      </code>
      
       tags; each starts connection work that can compete with critical resources
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        redundant-dns-prefetch
      </code>
    </td>
    
    <td>
      <code>
        info
      </code>
    </td>
    
    <td>
      Same origin has both <code>
        <link rel="preconnect">
      </code>
      
       and <code>
        <link rel="dns-prefetch">
      </code>
      
      ; preconnect already includes DNS resolution
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        preload-async-defer-conflict
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      A preloaded script also has <code>
        async
      </code>
      
       or <code>
        defer
      </code>
      
       and the preload is not marked <code>
        fetchpriority="low"
      </code>
      
      . Browsers allow this combination; the warning is the plugin's priority heuristic
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        prefetch-preload-conflict
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      Same resource has both <code>
        preload
      </code>
      
       and <code>
        prefetch
      </code>
      
      ; use preload for current page, prefetch for future navigation
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        inline-style-size
      </code>
    </td>
    
    <td>
      <code>
        info
      </code>
    </td>
    
    <td>
      Inline <code>
        <style>
      </code>
      
       exceeds the plugin's default 14KB threshold
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        inline-script-size
      </code>
    </td>
    
    <td>
      <code>
        info
      </code>
    </td>
    
    <td>
      Inline <code>
        <script>
      </code>
      
       exceeds 2KB; consider moving to an external file for cacheability
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        meta-beyond-1mb
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      The plugin's serialized-size estimate places a <code>
        <meta>
      </code>
      
       tag beyond its default 1MB inspection threshold
    </td>
  </tr>
</tbody>
</table>

## Rule configuration

Rules can be disabled or have their severity overridden, similar to ESLint's flat config:

<code-block>

```ts [Input]
ValidatePlugin({
  rules: {
    'missing-description': 'off',
    'viewport-user-scalable': 'off',
    'missing-title': 'info', // downgrade from warn to info
  }
})
```

</code-block>

Some rules accept an options object as an ESLint-style `[severity, options]` tuple:

<code-block>

```ts [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
  }
})
```

</code-block>

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:

<code-block>

```ts [Input]
ValidatePlugin({
  root: process.cwd(),
})
// output: [unhead] Canonical URL should be absolute, received "/page". (./src/components/MyPage.vue:42:5)
```

</code-block>

## Framework DevTools integration

The `onReport` callback receives structured rule objects that you can pass to a UI:

<code-block>

```ts [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
      })
    }
  }
})
```

</code-block>

## Related

- [Canonical Plugin](/docs/head/guides/plugins/canonical): Auto-resolve relative URLs to absolute
- [Infer SEO Meta](/docs/head/guides/plugins/infer-seo-meta-tags): Auto-generate OG and Twitter meta tags
- [useSeoMeta()](/docs/head/api/composables/use-seo-meta): Type-safe SEO meta management
