---
title: "ESLint Plugin"
description: "Catch unhead misuse, type-narrowing gaps, and v2-to-v3 migration problems at the source level. Pairs with the runtime ValidatePlugin and the unhead CLI."
canonical_url: "https://unhead.unjs.io/docs/head/guides/eslint-plugin"
last_updated: "2026-07-20T12:14:09.628Z"
---

`@unhead/eslint-plugin` checks `useHead`, `useSeoMeta`, and related calls for problems that TypeScript cannot detect. The recommended config enables 13 rules; the migration config also enables `prefer-define-helpers`.

## Reported problems

Type narrowing catches a lot of broken `useHead()` / `useSeoMeta()` calls, but not everything. The ESLint plugin walks tag literals at lint time and surfaces issues like:

- Deprecated v2 props (`children`, `hid`, `vmid`, `body: true`) with autofixes
- Empty meta content, HTML in titles, relative canonicals
- Conflicting robots directives, missing `crossorigin` on font preloads, missing `as` on preloads
- Typos in `name` / `property` (Levenshtein-suggested fixes)
- Numeric `tagPriority` (suggests the named `'critical'` / `'high'` / `'low'` form)
- Twitter handles missing `@`, accessibility-harmful viewport settings

These overlap with the runtime [Validate Plugin](/docs/head/guides/plugins/validate) and the `unhead` CLI by design: the same rule IDs flow through static lint, runtime warnings, and CLI reports.

## Setup

Install:

<code-block>

```bash [Terminal]
pnpm add -D @unhead/eslint-plugin
```

</code-block>

Wire up the recommended config in flat config:

<code-block>

```ts [eslint.config.ts]
import { configs } from '@unhead/eslint-plugin'

export default [
  configs.recommended,
]
```

</code-block>

The config registers the `@unhead` plugin namespace and enables every recommended rule. No `files` glob is needed because the rules inspect only calls into the Unhead API.

## The migration config

Swap `recommended` for `migration`:

<code-block>

```ts [eslint.config.ts]
import { configs } from '@unhead/eslint-plugin'

export default [
  configs.migration,
]
```

</code-block>

`configs.migration` enables everything in `recommended` plus `prefer-define-helpers`, which wraps `link` and `script` tag object literals in `defineLink` and `defineScript`. The rule can autofix when the corresponding helper is already imported; otherwise, it offers a suggestion. Combined with the autofixable `no-deprecated-props` rule, this handles much of a v2-to-v3 migration mechanically.

> `@unhead/cli` exposes the same rules through `unhead audit` and `unhead migrate`. It uses the oxc parser directly for `.ts`, `.tsx`, `.vue`, and `.svelte` files, without an ESLint parser configuration.

## Rules

<table>
<thead>
  <tr>
    <th>
      Rule
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Autofix
    </th>
    
    <th>
      What it catches
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        defer-on-module-script
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      ✓
    </td>
    
    <td>
      <code>
        <script type="module" defer>
      </code>
      
       (defer is redundant)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        empty-meta-content
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      
    </td>
    
    <td>
      <code>
        <meta name="description" content="">
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        no-deprecated-props
      </code>
    </td>
    
    <td>
      <code>
        error
      </code>
    </td>
    
    <td>
      ✓
    </td>
    
    <td>
      v2 props: <code>
        children
      </code>
      
      , <code>
        hid
      </code>
      
      , <code>
        vmid
      </code>
      
      , <code>
        body: true
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        no-html-in-title
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      
    </td>
    
    <td>
      HTML chars in <code>
        title
      </code>
      
       (will be escaped, not rendered)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        no-unknown-meta
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      ✓
    </td>
    
    <td>
      typos in <code>
        name
      </code>
      
       / <code>
        property
      </code>
      
       (Levenshtein-suggested fix)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        non-absolute-canonical
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      
    </td>
    
    <td>
      relative URLs in <code>
        <link rel="canonical">
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        numeric-tag-priority
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      suggestion
    </td>
    
    <td>
      numeric <code>
        tagPriority
      </code>
      
       (suggests <code>
        'critical'
      </code>
      
      , <code>
        'high'
      </code>
      
      , or <code>
        'low'
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        prefer-define-helpers
      </code>
    </td>
    
    <td>
      off (migration only)
    </td>
    
    <td>
      conditional
    </td>
    
    <td>
      wraps <code>
        link
      </code>
      
       / <code>
        script
      </code>
      
       literals in <code>
        defineLink
      </code>
      
       / <code>
        defineScript
      </code>
      
      ; autofixes when the helper is imported
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        preload-font-crossorigin
      </code>
    </td>
    
    <td>
      <code>
        error
      </code>
    </td>
    
    <td>
      ✓
    </td>
    
    <td>
      font preloads missing <code>
        crossorigin
      </code>
      
       (would refetch)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        preload-missing-as
      </code>
    </td>
    
    <td>
      <code>
        error
      </code>
    </td>
    
    <td>
      
    </td>
    
    <td>
      <code>
        <link rel="preload">
      </code>
      
       missing required <code>
        as
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        robots-conflict
      </code>
    </td>
    
    <td>
      <code>
        error
      </code>
    </td>
    
    <td>
      
    </td>
    
    <td>
      <code>
        index, noindex
      </code>
      
       or <code>
        follow, nofollow
      </code>
      
       in robots meta
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        script-src-with-content
      </code>
    </td>
    
    <td>
      <code>
        error
      </code>
    </td>
    
    <td>
      
    </td>
    
    <td>
      a script with both <code>
        src
      </code>
      
       and inline content
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        twitter-handle-missing-at
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      ✓
    </td>
    
    <td>
      <code>
        twitter:site
      </code>
      
       / <code>
        twitter:creator
      </code>
      
       missing <code>
        @
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        viewport-user-scalable
      </code>
    </td>
    
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      
    </td>
    
    <td>
      <code>
        user-scalable=no
      </code>
      
       or <code>
        maximum-scale=1
      </code>
      
       (accessibility)
    </td>
  </tr>
</tbody>
</table>

## Inspected calls

Rules apply to source-level calls into the unhead API:

- `useHead`, `useHeadSafe`, `useServerHead`, `useServerHeadSafe`
- `useSeoMeta`, `useServerSeoMeta`
- The tag helpers `defineLink` and `defineScript`

Tag arrays inside `meta` / `link` / `script` / `noscript` / `style` keys are descended automatically, so a typo inside a `link[3]` literal still gets caught.

## Runtime-only checks

ESLint rules can only see what's expressible in the AST. Cross-tag and rendered-output checks live in the runtime [Validate Plugin](/docs/head/guides/plugins/validate) and are surfaced by the `unhead validate-url` / `unhead validate-html` CLI commands. Examples that need rendered output:

- `canonical-og-url-mismatch`: needs both tags resolved together
- `meta-beyond-1mb`: depends on rendered byte position
- `charset-not-early`: depends on tag ordering after deduplication
- `too-many-preloads`: depends on the merged tag set across all entries

The plugin imports its rule IDs and known-meta sets from `unhead/validate`, the same module the runtime plugin reads, so lint diagnostics, runtime warnings, and CLI reports stay aligned by construction.

## Related

- [Validate Plugin](/docs/head/guides/plugins/validate): Runtime validation for cross-tag and rendered-output issues
- [useHead()](/docs/head/api/composables/use-head): Typed head tag management
- [useSeoMeta()](/docs/head/api/composables/use-seo-meta): Typed SEO metadata
