---
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-05-14T22:07:48.622Z"
---

**Quick Answer:** `@unhead/eslint-plugin` lints calls into `useHead`, `useSeoMeta`, and friends for issues TypeScript can't catch. The recommended config wires up 13 rules and an autofix-driven migration mode.

## What Does This Plugin Do?

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.

## How Do I Set Up the Plugin?

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>

That's it. The config registers the `@unhead` plugin namespace and enables every recommended rule. No `files` glob is needed: the rules only fire on calls into the unhead API, so non-head code is silently ignored.

## How Do I Migrate from v2?

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` / `script` tag object literals in `defineLink` / `defineScript` via autofix. Combined with `no-deprecated-props` (also autofixable), `eslint --fix` handles the bulk of a v2-to-v3 migration mechanically.

> Don't want to wire ESLint? `@unhead/cli` ships the same rules as a standalone command (`unhead audit` / `unhead migrate`) running on a native oxc parser, no parser configuration, works out-of-the-box on `.ts` / `.tsx` / `.vue` / `.svelte`.

## What Rules Are Included?

<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>
      ✓
    </td>
    
    <td>
      wraps <code>
        link
      </code>
      
       / <code>
        script
      </code>
      
       literals in <code>
        defineLink
      </code>
      
       / <code>
        defineScript
      </code>
    </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>

## What Calls Does It Walk?

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.

## What Can't It Catch?

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 equivalent that catches cross-tag and rendered-output issues
- [useHead()](/docs/head/api/composables/use-head) - Type-safe head tag management
- [useSeoMeta()](/docs/head/api/composables/use-seo-meta) - Type-safe SEO meta management
