Si tu viens de passer sur ESLint 9 et que tu as eu ce moment de solitude où ton projet React semble soudain « cassé » (plugins introuvables, extends invalides, erreurs ESM…), respire. La plupart du temps, ce n’est pas ton code. C’est ta config qui reposait sur des comportements implicites, des presets « magiques », et un écosystème qui a changé de règles.
Je te propose un plan de migration ESLint 8 → 9 qui tient en une heure si tu restes discipliné. Avec, surtout, une idée fixe : rendre explicite ce qui l’était trop peu. À la fin, tu dois avoir un eslint.config.js lisible, versionné, et boring. Comme un bon outil.
ESLint 9 et la flat config : pourquoi ça explose d’un coup
La bascule vers ESLint 9, ce n’est pas « juste » une montée de version. Le changement qui fait mal, c’est la flat config (le fichier eslint.config.js) qui remplace progressivement le vieux monde .eslintrc et sa mécanique d’extends. Dans le legacy, tu empilais des presets et ESLint allait chercher plein de choses dans ton graphe npm, parfois via des dépendances transitoires. Dans la flat config, tu es beaucoup plus proche de la réalité Node : tu importes des configs, tu déclares tes plugins, tu contrôles l’ordre.
Et oui, ça veut dire que des projets qui « marchaient » grâce à des dépendances installées ailleurs (un preset qui embarquait un plugin, un framework qui installait ESLint pour toi, un monorepo qui résolvait par chance) peuvent se mettre à hurler. ESLint 9 ne devient pas méchant, il devient plus strict sur la résolution et sur ce qui est réellement présent.
Sponsorisé par Le Scribouillard
Besoin de contenu optimisé SEO ?
Utilisez la meilleure plateforme française de création de contenu assistée par IA ! Et générez des articles pour moins de 1€ !
Les erreurs qui te font croire que Vite/Next/React a explosé (et la vraie cause)
La plus fréquente, c’est l’erreur ESM. Typiquement : require is not defined in ES module scope ou l’inverse, Cannot use import statement outside a module. Là, tu n’es pas en train de débugger ESLint. Tu débugges Node et la manière dont ton fichier de config est interprété. Si ton repo est en CommonJS, un eslint.config.js avec des import peut partir en vrille. Et si ton repo est en ESM (ou que tu l’as rendu ESM avec "type": "module"), un config qui traîne encore des require() devient invalide.
Deuxième famille d’erreurs : « plugin introuvable » et « rule not found ». Exemple classique : ESLint couldn't find the plugin "eslint-plugin-react" ou Definition for rule "react/jsx-uses-react" was not found. Avant, tu pouvais t’en sortir parce que ta chaîne d’outillage te l’amenait indirectement. Avec ESLint 9, si tu veux des règles React, tu installes le plugin React. Si tu veux Prettier, tu installes la config Prettier. Point.
Troisième douleur : les anciens extends qui ne veulent plus rien dire. En flat config, tu ne balances pas un extends: ["plugin:react/recommended"] dans un objet et tu pries. Tu importes le plugin et tu réutilises sa config (ou tu passes par un adaptateur si tu dois réutiliser des presets legacy). C’est ça, « rendre explicite ».
Plan de migration ESLint 8 → 9 en 60 minutes (sans héroïsme)
Je commence toujours par un truc simple : je me crée une branche dédiée et je décide tout de suite de mon plan de repli. Dans le package.json, je sais déjà quelle version d’ESLint je vais repinner si ça dérape, et je m’assure que la CI ne va pas me bloquer pendant que j’itère. Ça paraît bête, mais sans ce filet, tu vas te retrouver à « réparer » un linter sous pression parce que personne ne peut merge.
Ensuite, je choisis un périmètre pilote. Pas tout le monorepo. Un package React typique, ou une app Vite. L’objectif est de trouver la forme finale de la config, puis de l’étendre. Tant que tu n’as pas réussi à faire tourner npx eslint . sur un sous-ensemble, tu n’as rien gagné.
Après seulement, je fais l’inventaire de l’implicite. Je regarde le .eslintrc (ou ce que Next/Vite utilisait), et je note les presets : React, React Hooks, TypeScript, Prettier, Next, import, jsx-a11y… Tout ce qui était « caché » derrière un extends doit devenir un import et/ou un package installé.
Enfin, j’active le mode diagnostic. npx eslint . --debug est moche, mais il te dit exactement ce qu’il essaie de résoudre et d’où viennent les règles. Et si tu veux vérifier ce qu’ESLint applique réellement à un fichier, --print-config te fait gagner du temps, surtout quand tu as des overrides par glob.
Traduire un .eslintrc en eslint.config.js : ce qui change vraiment
La flat config, ce n’est pas « le même JSON en JS ». C’est une liste de blocs appliqués dans l’ordre. Concrètement, tu vas définir des files, des languageOptions (parser, globals, ecmaVersion…), tes plugins, puis tes rules. L’ordre compte plus qu’avant, et c’est une bonne chose : tu peux finir par la config Prettier pour neutraliser les règles de formatage qui se battent avec ton formatter.
Le piège classique, c’est de migrer en copiant la logique d’extends à l’identique, puis de passer la journée à comprendre pourquoi une règle ne s’applique plus. En flat config, je conseille d’être brutal : tu pars d’un socle minimal (JS recommandé), tu ajoutes TypeScript si tu en as, puis React, puis Prettier. Et tu vérifies à chaque étape que tu lances ESLint et que tu comprends le résultat. Si tu ajoutes 5 presets d’un coup, tu vas juste te fabriquer un bug de plus à déboguer.
Config prête à copier : Vite + React + TypeScript + Prettier (ESM)
Voici une base que j’utilise en Vite React TypeScript. Elle suppose une config ESM. Si ton repo n’est pas en ESM, deux options propres : soit tu passes le fichier en eslint.config.mjs, soit tu restes en CommonJS avec eslint.config.cjs et tu adaptes les imports en require. Ne mélange pas les deux.
Côté dépendances, oui, c’est plus verbeux qu’avant. Mais au moins tu sais ce qui tourne : npm i -D eslint @eslint/js globals typescript-eslint eslint-plugin-react eslint-plugin-react-hooks eslint-config-prettier.
// eslint.config.js (ESM)
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import prettier from "eslint-config-prettier";
export default [
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{js,jsx,ts,tsx}"]
,
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
...globals.es2021
}
},
plugins: {
react,
"react-hooks": reactHooks
},
settings: {
react: { version: "detect" }
},
rules: {
...reactHooks.configs.recommended.rules
}
},
// Toujours en dernier : désactive ce qui entre en conflit avec Prettier
prettier
];
Deux remarques terrain. D’abord, si tu avais des règles React héritées d’un preset type CRA, tu ne retrouveras pas exactement la même sévérité sans remettre les mains dedans. C’est normal. Ensuite, si tu actives du lint TypeScript « type-aware » trop tôt, tu peux transformer ton lint en camion. Tant que tu n’as pas besoin des règles basées sur le type-checker, reste sur le recommandé simple.
Config prête à copier : lib TypeScript (Node) sans se tirer une balle
Pour une lib TypeScript (pas une app React), je préfère une config qui assume l’environnement Node, et qui sépare bien les fichiers compilés. Le truc qui fait perdre du temps ici, c’est de lint du dist, ou d’avoir une config qui croit qu’on est dans le navigateur. Et si tu publies une lib, tu veux aussi éviter les règles trop agressives qui cassent à chaque refactor.
// eslint.config.js (ESM) - lib TS Node
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import prettier from "eslint-config-prettier";
export default [
{ ignores: ["dist/**", "coverage/**", "**/*.d.ts"] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.ts"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.node
}
},
rules: {
// Exemple utile en lib : tu peux le durcir si tu veux
"no-console": "off"
}
},
prettier
];
Si tu tiens à activer les règles type-aware (celles qui demandent un tsconfig), fais-le comme un choix conscient, pas comme un copier-coller. Sur des repos un peu gros, c’est là que tu peux passer d’un lint en 3 secondes à un lint en 40 secondes, et ça change tout côté DX et CI.
Next.js : le cas à part (et comment éviter le double-lint)
Next ajoute sa couche, parce que beaucoup d’équipes utilisent next lint et eslint-config-next. Ce que je vois planter, c’est le mélange des mondes : une flat config d’un côté, et un Next qui s’attend encore à certaines conventions legacy de l’autre, ou qui lance ESLint avec des options que tu n’as pas anticipées.
Mon conseil est simple : décide qui pilote. Soit tu continues à utiliser next lint comme commande officielle et tu adaptes ta config pour que Next soit content. Soit tu assumes eslint . comme commande unique (et tu ajustes les scripts), en vérifiant que tu gardes bien les règles Next qui t’intéressent vraiment. Dans les deux cas, évite le scénario « je lance next lint en CI et eslint en local » avec deux configs différentes. C’est comme ça que tu crées des bugs fantômes.
Et si tu dois absolument réutiliser des presets legacy qui n’ont pas de version flat config propre, regarde du côté d’un adaptateur comme FlatCompat (package @eslint/eslintrc). Ce n’est pas sexy, mais ça te permet de migrer sans tout réécrire en une fois. L’important, c’est de le voir comme une étape transitoire, pas comme un état final.
Monorepo : quand un seul eslint.config.js ne suffit pas
En monorepo, le piège n’est pas ESLint. C’est la résolution des modules et des tsconfig. Une flat config à la racine peut marcher, mais dès que tu actives TypeScript sérieusement, tu te retrouves vite avec des chemins de projet différents, des tsconfig.json par package, et des patterns d’ignore qui doivent être carrés.
Ce que je vise : une config racine qui donne le socle commun (JS, règles de base, Prettier, ignores globaux), puis des blocs files ciblés par workspace. Tu évites ainsi de faire croire à ESLint qu’un package Node est une app browser, ou qu’un package React est une lib. Le second piège, c’est l’ordre. En flat config, l’ordre des blocs change réellement ce qui est appliqué. Quand ça diverge entre packages, tu veux que ce soit volontaire, lisible, et testé.
Rollback propre : pinner ESLint, figer la CI, et repartir
Je le dis franchement : si tu as une deadline, ne fais pas ta migration ESLint 9 en mode « YOLO » sur la branche principale. Le plan de repli doit être prêt avant de commencer. Dans la pratique, ça veut dire pinner la version (et parfois pinner aussi un plugin qui casse), s’assurer que la CI utilise bien npm ci ou un lockfile strict, et avoir une PR petite et reversible.
Quand ça part en vrille, le bon rollback est ennuyeux : tu repinnes, tu merges, tu respires. Puis tu reviens sur la migration avec un repo pilote, un diff propre, et du temps. L’inverse, c’est la journée perdue à patcher au hasard parce qu’« il faut que ça passe ». ESLint n’a rien demandé.
Conclusion : ESLint 9 devient sympa quand tu t’arrêtes de lui cacher des trucs
La migration vers ESLint 9 est pénible pour une raison très précise : elle révèle ce qui était implicite dans ton outillage. Une fois que tu as payé ce coût et que tu as un eslint.config.js explicite, l’effet est plutôt positif. Tes dépendances sont claires, la config est plus lisible, et tu peux raisonner sur ce qui s’applique vraiment à un fichier.
Si tu veux aller plus loin, le bon prochain pas n’est pas d’ajouter 15 plugins. C’est de stabiliser la base, de mesurer le coût du lint en CI, puis de décider où le lint doit être strict (code produit) et où il doit rester tolérant (scripts, tests, prototypes). C’est là que le gain DX est réel.