---
title: "Validate"
description: "Catch common SEO, performance, and head tag mistakes. Validates URLs, meta tags, Open Graph, Twitter Cards, web vitals anti-patterns, and detects typos with fuzzy matching."
canonical_url: "https://unhead.unjs.io/docs/head/guides/plugins/validate"
last_updated: "2026-06-30T06:49:04.955Z"
---

**Quick Answer:** The Validate plugin catches common head tag mistakes — non-absolute URLs, missing OG tags, typos in meta properties, conflicting robots directives, and more. It runs only when you register it and is fully tree-shakeable.

## What Does This Plugin Do?

The Validate plugin inspects the final resolved head output and warns about issues that TypeScript can't catch:

- **URL problems** — relative canonical/OG URLs, canonical vs og:url mismatches
- **Missing tags** — no title, no description (when indexable), missing OG companions
- **Content issues** — empty meta content, HTML in title, unresolved template params
- **Conflicts** — contradictory robots directives, accessibility-harmful viewport settings
- **Typos** — fuzzy-matches unknown meta properties/names against known values with "Did you mean?" suggestions

## How Do I Set Up the Plugin?

Register the plugin when you want head tag validation — it's fully tree-shakeable when not imported:

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

## What Options Can I Configure?

<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 rule severity. Set to 'off' to disable, or 'warn'/'info' to override.
   */
  rules?: Partial<Record<string, 'warn' | 'info' | 'off'>>
  /**
   * Project root path. When set, source locations are displayed as relative paths (e.g. ./src/components/MyPage.vue:42:5).
   */
  root?: string
}
```

</code-block>

## What Rules Are Included?

### URL Validity

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

### Missing Companion Tags

<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 but <code>
        og:image:width
      </code>
      
       and/or <code>
        og:image:height
      </code>
      
       are missing — social platforms may not display the image
    </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>
      
       — the font will be fetched twice
    </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 (e.g., <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 doesn't 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>
      
       that is close to a known value. Uses fuzzy matching to suggest corrections: <code>
        og:titl
      </code>
      
       → "Did you mean <code>
        og:title
      </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

Rules inspired by [webperf-snippets](https://webperf-snippets.nucliweb.net/) that catch common performance anti-patterns in head tags:

<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>
      
       blocks the critical rendering path
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        too-many-fetchpriority-high
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      More than 2 resources with <code>
        fetchpriority="high"
      </code>
      
      . When everything is high priority, nothing is
    </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>
      <code>
        <meta charset>
      </code>
      
       is not within the first few tags in <code>
        <head>
      </code>
      
      , which can force the browser to re-parse
    </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>
      <code>
        <link rel="preload" fetchpriority="low">
      </code>
      
       is contradictory — preload signals critical, low priority contradicts that
    </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-preconnects
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      More than 4 <code>
        <link rel="preconnect">
      </code>
      
       tags — each initiates a TCP+TLS handshake, competing for limited connections
    </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 script is preloaded but also has <code>
        async
      </code>
      
       or <code>
        defer
      </code>
      
       — preload escalates the priority, defeating the purpose
    </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 14KB (the critical CSS budget for the first TCP round-trip)
    </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>
      A <code>
        <meta>
      </code>
      
       tag is rendered beyond the 1MB crawler parsing limit — social crawlers (Facebook, Twitter) may not see it
    </td>
  </tr>
</tbody>
</table>

## How Do I Configure Rules?

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

The configuration is fully type-safe — only rules that support options accept the tuple form, and options are typed per-rule.

## How Does Source Tracing Work?

Each validation rule includes a `source` field pointing to the `head.push()` call that introduced the problematic tag. By default this is an absolute path. Set `root` to get clickable relative paths in your terminal or IDE:

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

## How Do I Integrate with Framework DevTools?

The `onReport` callback receives structured rule objects, making it easy to integrate with any 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
