---
createdAt: 2026-08-12
updatedAt: 2026-08-13
title: ESLint Plugin | Lint rules for Intlayer
description: Catch hardcoded strings, dynamic calls the Intlayer compiler cannot optimize, and unused dictionary content, with eslint-plugin-intlayer. Works with ESLint and oxlint, across React, Vue, Svelte, Angular and Astro.
keywords:
- Intlayer
- ESLint
- oxlint
- Linting
- i18n
- Internationalization
- no-raw-text
- Hardcoded strings
- Unused translations
- Dead content
- React
- Vue
- Svelte
- Angular
slugs:
- doc
- eslint
history:
- version: 9.3.1
date: 2026-08-12
changes: "Init history"
author: aymericzip
---
# ESLint x OXLint Plugin
`eslint-plugin-intlayer` catches the kinds of i18n mistake TypeScript cannot:
1. **Hardcoded text** that never made it into a dictionary.
2. **Dynamic calls** that type-check and run, but that the Intlayer compiler cannot optimize.
3. **Dead content** — dictionaries and fields nothing in the project reads (opt-in).
Unknown dictionary keys, unknown field paths and missing locales are already compile errors, so the plugin does not repeat them.
## Installation
```bash packageManager="npm"
npm install --save-dev eslint-plugin-intlayer
```
```bash packageManager="pnpm"
pnpm add --save-dev eslint-plugin-intlayer
```
```bash packageManager="yarn"
yarn add --dev eslint-plugin-intlayer
```
Requires ESLint 9 or later (flat config). ESLint 10 is supported.
## Usage
The plugin runs in both ESLint and [oxlint](https://oxc.rs) — the same rules, the same options.
```javascript fileName="eslint.config.mjs" codeFormat="esm"
import intlayer from "eslint-plugin-intlayer";
export default [...intlayer.configs.recommended];
```
Or spread a config and set the severities yourself:
```javascript fileName="eslint.config.mjs" codeFormat="esm"
import intlayer from "eslint-plugin-intlayer";
export default [
...intlayer.configs.recommended,
{
rules: {
"intlayer/no-raw-text": "warn",
"intlayer/static-dictionary-key": "error",
"intlayer/no-dynamic-field-access": "error",
"intlayer/enforce-adapter-import": "warn",
"intlayer/no-unused-content": "warn",
},
},
];
```
```json fileName=".oxlintrc.json"
{
"jsPlugins": ["eslint-plugin-intlayer"],
"rules": {
"intlayer/no-raw-text": "warn",
"intlayer/static-dictionary-key": "error",
"intlayer/no-dynamic-field-access": "error",
"intlayer/enforce-adapter-import": "warn"
}
}
```
Two caveats: oxlint's JS plugin support is still alpha, and oxlint does not support custom parsers — so `.vue`, `.svelte`, `.astro` and Angular templates are not linted there. Run oxlint over your JS/TS/JSX files and keep ESLint for the rest.
`no-unused-content` is left out above on purpose: it needs the working directory and the linted file path from the rule context, which the alpha JS plugin bridge does not guarantee. Run it under ESLint.
### Configs
| Config | `no-raw-text` | `static-dictionary-key` | `no-dynamic-field-access` | `enforce-adapter-import` | `no-unused-content` |
| --------------- | -------------------------- | ----------------------- | ------------------------- | ------------------------ | ------------------- |
| `recommended` | warn | error | error | off | off |
| `strict` | error (+ non-JSX literals) | error | error | error | off |
| `contract-only` | off | error | error | off | off |
`recommended` keeps `no-raw-text` at `warn` on purpose: pointing it at an existing codebase surfaces every untranslated string at once, which should not break your build on day one.
`enforce-adapter-import` is off by default — enable it explicitly if you want it.
`no-unused-content` is off in every config, `strict` included. It is the one rule that reads your Intlayer configuration and walks your source files from disk, so turning it on should be a deliberate choice rather than something a preset does for you.
## Rules
### `no-raw-text`
Reports user-facing text that is not declared in a dictionary. It uses the same detection as `intlayer extract`, so brand names, CSS classes and technical identifiers are ignored.
```jsx
// ✗ Reported
Welcome to our documentation
// ✓ Fine
const { title } = useIntlayer("home");
{title}
```
Content declaration files (`*.content.ts`, …) are skipped.
To fix a whole file at once, run `npx intlayer extract` and let the compiler move the strings into a dictionary for you.
**Options**
```javascript fileName="eslint.config.mjs" codeFormat="esm"
{
"intlayer/no-raw-text": [
"warn",
{
// Attributes whose value is user-facing text.
// Default: title, placeholder, alt, aria-label, label
attributes: ["title", "placeholder", "alt", "aria-label", "label"],
// Elements whose content is never user-facing text.
// Default: code, pre, script, style
ignoreElements: ["code", "pre", "script", "style"],
// Regular expressions for text to never report.
ignorePatterns: ["^Powered by"],
// Also report string literals outside markup. Default: false
includeStringLiterals: false,
},
],
}
```
### `static-dictionary-key`
Requires the dictionary key to be a string literal.
The compiler can only pre-load a dictionary when it can read the key directly at the call site. With a computed key it silently skips the optimization and bundles every dictionary instead.
```typescript
// ✗ Reported
useIntlayer(dictionaryKey);
useIntlayer(`home-${suffix}`);
getTranslations({ namespace: page });
// ✗ A variable is still not a literal
const key = "home";
useIntlayer(key);
// ✓ Fine
useIntlayer("home");
getTranslations({ namespace: "home" });
```
This applies to `useIntlayer`, `getIntlayer` and every compat adapter (`useTranslation`, `useTranslations`, `formatMessage`, ``, ``, …).
### `no-dynamic-field-access`
Requires the field you read from a dictionary to be statically known.
The compiler removes fields it does not see used. A computed access is invisible to it, so the read can return `undefined` at runtime.
```typescript
// ✗ Reported
const content = useIntlayer("home");
content[fieldName];
const t = useTranslations("home");
t(messageKey);
// ✓ Fine
content.title;
content["title"];
content.items[0];
t("hero.title");
```
### `enforce-adapter-import`
Prefers the `@intlayer/*` compat adapter over the original package. The original only resolves to Intlayer when the bundler alias is configured; the adapter always does. Autofixable with `--fix`.
```typescript
// ✗ Reported
import { useTranslation } from "react-i18next";
import { getTranslations } from "next-intl/server";
// ✓ Fine
import { useTranslation } from "@intlayer/react-i18next";
import { getTranslations } from "@intlayer/next-intl/server";
```
### `no-unused-content`
**Off by default.** Reports content nothing in your project reads, plus dictionary keys declared in more than one place.
```typescript fileName="src/home.content.ts"
export default {
key: "home", // ✗ Reported when no caller anywhere asks for "home"
content: {
title: t({ "en-GB": "Title", en: "Title" }),
// ✗ Reported when nothing reads `hero`
hero: {
subtitle: t({ "en-GB": "Subtitle", en: "Subtitle" }),
},
},
};
```
Unlike the other rules, this one cannot answer from the file in front of it — a field is unused only relative to the whole project. On the first content declaration of a lint run it loads your Intlayer configuration, globs the source files that configuration declares (`build.traversePattern`, `compiler.transformPattern`) and runs the same usage analyser that powers `@intlayer/lsp` and the "unused" strikethrough in the VS Code extension. The result is cached for `cacheTtl` milliseconds, so the scan happens once per run rather than once per file.
**Options**
```javascript fileName="eslint.config.mjs" codeFormat="esm"
{
"intlayer/no-unused-content": [
"warn",
{
// Report dictionary keys nothing references. Default: true
reportUnusedDictionaries: true,
// Report content fields nothing reads. Default: true
reportUnusedFields: true,
// Report keys declared in more than one place. Default: true
reportDuplicateKeys: true,
// Regular expressions for field paths to never report.
ignoreFields: ["^meta"],
// Project root the scan starts from. Default: ESLint's working directory
baseDir: process.cwd(),
// How long one project scan is reused, in ms. Default: 30000
cacheTtl: 30000,
},
],
}
```
Lower `cacheTtl` when you lint from a long-lived editor server and want your edits reflected sooner; set `baseDir` when a single lint run spans several Intlayer projects in a monorepo.
> **It errs towards silence.** A false positive here deletes a translation, so nothing is reported when the dictionary is consumed in a way the analysis cannot follow: the content object passed on as a whole, a translator function bound from it (`const t = useTranslations("home")`), a declaration reached through a direct import (`useDictionary(myDictionary)`), a `nest()` from another dictionary, or a field list made non-exhaustive by a spread. Single-file components (`.vue`, `.svelte`, `.astro`) count as using every field of the dictionaries they mention, because their script blocks are not parsed here.
`reportDuplicateKeys` reads the unmerged dictionaries the build writes under `.intlayer/`, so it stays quiet until the project has been built at least once. Two declarations sharing a key are merged, which is a legitimate pattern — the report exists because a field defined on both sides silently keeps only one of the two values.
The analyser is loaded from `@intlayer/lsp`, which ships as ESM. The rule therefore needs a Node version that can `require()` an ES module — Node 20.19+ or 22.12+. On anything older it reports nothing rather than failing the lint run.
## Frameworks
Every rule works across all Intlayer integrations, including inside Vue, Svelte and Angular templates. You only need to tell ESLint which parser reads each file type.
| Framework | Files | Parser |
| ------------------------- | ----------------- | --------------------------------- |
| React, Preact, Solid, Lit | `.jsx` `.tsx` | `typescript-eslint` |
| Next.js | `.jsx` `.tsx` | `typescript-eslint` |
| Vue, Nuxt | `.vue` | `vue-eslint-parser` |
| Svelte, SvelteKit | `.svelte` | `svelte-eslint-parser` |
| Angular | `.ts` | `typescript-eslint` |
| Angular templates | `.component.html` | `@angular-eslint/template-parser` |
| Astro | `.astro` | `astro-eslint-parser` |
```javascript fileName="eslint.config.mjs" codeFormat="esm"
import intlayer from "eslint-plugin-intlayer";
import tseslint from "typescript-eslint";
import vueParser from "vue-eslint-parser";
import svelteParser from "svelte-eslint-parser";
import angularTemplateParser from "@angular-eslint/template-parser";
export default [
...intlayer.configs.recommended,
{
files: ["**/*.{ts,tsx,jsx}"],
languageOptions: { parser: tseslint.parser },
},
{
files: ["**/*.vue"],
languageOptions: {
parser: vueParser,
parserOptions: { parser: tseslint.parser },
},
},
{
files: ["**/*.svelte"],
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tseslint.parser },
},
},
{
files: ["**/*.component.html"],
languageOptions: { parser: angularTemplateParser },
},
];
```
Install only the parsers your project needs.
> **Known limitation.** In Vue and Angular templates, an expression such as `{{ content[key] }}` is not checked by `no-dynamic-field-access`. Dynamic reads written in the script block are caught normally.