@uxf/eslint-config
v14.1.0
Published
Shared ESLint 9 flat config for UXF frontend projects (TypeScript + React + Tailwind CSS).
Readme
@uxf/eslint-config
Shared ESLint 9 flat config for UXF frontend projects (TypeScript + React + Tailwind CSS).
When to use
Use it as the base ESLint configuration in any UXF frontend project (Next.js apps, React libraries, this monorepo's own root). It bundles the plugin set and the opinionated rule choices shared across UXF, so a project only adds its tsconfig wiring and any project-specific overrides.
Requires ESLint 9+ flat config (eslint.config.mjs). The project's own TypeScript version is irrelevant to linting — this package ships the TypeScript build that typescript-eslint needs (see TypeScript 7). It does not support the legacy .eslintrc / .eslintignore format. Migrating an ESLint 8 project? Follow MIGRATION.md.
Installation
yarn add -D @uxf/eslint-config @uxf/browserslist-config eslint @eslint/js eslint-import-resolver-typescripteslint, @eslint/js, eslint-import-resolver-typescript and @uxf/browserslist-config are peer dependencies (yarn dependencies:fix installs them in a project with @uxf/scripts; without it, add them by hand, or ESLint fails to load the config). All linting plugins (eslint-plugin-import, -react, -react-hooks, -jsx-a11y, -tailwindcss, -compat, -n, typescript-eslint, globals) plus the typescript build used by typescript-eslint ship as direct dependencies of this package — do not install them yourself.
eslint-import-resolver-typescript in particular must live in the project root's node_modules (hence a peer dependency), because eslint-plugin-import resolves it from the linted file's context, not from this package's own dependencies.
| Peer dependency | Version |
| ----------------------------------- | -------- |
| eslint | ^9.0.0 |
| @eslint/js | ^9.0.0 |
| eslint-import-resolver-typescript | ^4.4.5 |
| @uxf/browserslist-config | exact |
@uxf/browserslist-config is the company list of supported browsers that compat/compat checks against; yarn dependencies:fix installs it.
Quick start
The package is ESM-only ("type": "module"). Create eslint.config.mjs in your project root (or eslint.config.js if your project itself is "type": "module") and spread the default export:
import uxfConfig from "@uxf/eslint-config";
/** @type {import("eslint").Linter.Config[]} */
export default [
// Global ignores (replaces .eslintignore)
{
ignores: ["**/node_modules/", "**/*.js", "**/*.jsx", "**/*.d.ts"],
},
...uxfConfig,
// Project-specific config
{
languageOptions: {
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: import.meta.dirname,
},
},
},
];ESLint 9 auto-discovers eslint.config.mjs, so scripts are just eslint . / eslint . --fix.
With Next.js (eslint-config-next)
For Next.js 15+ with native flat config support:
import uxfConfig from "@uxf/eslint-config";
import nextPlugin from "@next/eslint-plugin-next";
/** @type {import("eslint").Linter.Config[]} */
export default [
{ ignores: ["**/node_modules/", ".next/", "**/*.js", "**/*.d.ts"] },
...uxfConfig,
// Next.js rules
{
plugins: { "@next/next": nextPlugin },
rules: {
...nextPlugin.configs.recommended.rules,
...nextPlugin.configs["core-web-vitals"].rules,
},
},
// Project config
{
languageOptions: {
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: import.meta.dirname,
},
},
},
];For Next.js 14 using the legacy eslint-config-next, use @eslint/eslintrc's FlatCompat:
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
import uxfConfig from "@uxf/eslint-config";
const __dirname = dirname(fileURLToPath(import.meta.url));
const compat = new FlatCompat({ baseDirectory: __dirname });
/** @type {import("eslint").Linter.Config[]} */
export default [
{ ignores: ["**/node_modules/", ".next/", "**/*.js", "**/*.d.ts"] },
...uxfConfig,
...compat.extends("next/core-web-vitals"),
{
languageOptions: {
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
},
},
},
];What's included
The config composes these base sets (in order), then applies a project rule layer and two file-scoped overrides:
@eslint/jsrecommended+typescript-eslintrecommendedeslint-plugin-import(flatConfigs.recommended)eslint-plugin-react(configs.flat.recommended, registered with the ref exception indestructuring-assignmentdescribed below)eslint-plugin-jsx-a11y(flatConfigs.strict)eslint-plugin-tailwindcss(configs["flat/recommended"], withclassnames-orderandno-custom-classnameturned off)eslint-plugin-react-hooks(plugin registered;rules-of-hooksandexhaustive-depsset explicitly, plus the React Compiler rules ofrecommended-latestas warnings — no recommended spread)eslint-plugin-compat–compat/compatas an error: a web API (URLPattern,requestIdleCallback,ClipboardItem…) or an ES built-in on a global (Intl.Segmenter) that a supported browser lacks. The targets come from@uxf/browserslist-config, even in a project whosepackage.jsonhas nobrowserslist; a project's ownbrowserslistcan add older browsers but not raise that floor. A green lint is no proof of support: the plugin's API data is frozen at about the end of 2024 (Promise.try,URL.parse,Iterator.frompass unnoticed), instance methods (arr.toSorted()) are not checked, and only anifcounts as a feature check (typeof x === "function" ? x() : …andwindow.x?.()are still reported). The plugin matches identifiers by name, soCookieStoreis listed insettings.polyfills(acookieStorefromnext/headersruns on the server); a project that sets its ownpolyfillsreplaces that list and has to repeat"CookieStore". Turn the rule off for code that runs on Node.js only (CLIs, build scripts) if it reports a Node API.eslint-plugin-n– onlyn/no-deprecated-apias an error:url.parse,new Buffer(),fs.existsand the like. Node.js runs on the latest LTS everywhere, so version-range checks are not enabled.
It also sets languageOptions.globals to browser + node + jest (no env needed in consumers), settings.react.version to "detect", and the TypeScript import/resolver.
Notable custom rules
Opinionated rules that shape how conforming code must be written:
- React components are function declarations —
react/function-component-definition: { namedComponents: "function-declaration" };react/react-in-jsx-scope: off;react/prop-types: off. - No prop destructuring —
react/destructuring-assignment: "never"(useprops.x), with one exception: a declaration that destructures nothing but ref props,const { ref } = props;orconst { ref: forwardedRef, inputRef } = props;(refor a name ending inRef). The React Compiler treatspropsas a ref onceprops.ref(orprops.xRefpassed toref={…}) is read, and then skips the whole component, so ref props are read this way. Signature destructuring and rest patterns stay errors. - React Compiler rules as warnings —
react-hooks/config,error-boundaries,gating,globals,immutability,incompatible-library,preserve-manual-memoization,purity,refs,set-state-in-effect,set-state-in-render,static-components,unsupported-syntax,use-memo,void-use-memo. A warning marks code the compiler skips (or would compile unsafely); withreactCompileroff they are advice, with it on they explain why a component stayed unoptimised. - Components must be memoized —
react/require-optimization: error. - Sorted JSX props —
react/jsx-sort-props; plusjsx-boolean-value: never,self-closing-comp,jsx-curly-brace-presence,jsx-no-useless-fragment. - No
FC/PropsWithChildrenfromreact— viano-restricted-imports(seeRESTRICTED_IMPORT_PATHSbelow). no-restricted-syntaxblocks: numeric index access (arr[0]→ use.at()); bareRecord<string, …>(wrap inPartial<Record<…>>or narrow the key);Partial<Record<string, any>>(useAnyObjectfrom@uxf/core/types).- Function members as properties, not method shorthand —
@typescript-eslint/method-signature-style: "property", so an interface member is writtengetItem: (key: string) => void, nevergetItem(key: string): void. Method shorthand is bivariant in its parameters —strictFunctionTypesdeliberately exempts method signatures for compatibility with lib types likeArray.push— so it silently accepts an implementation whose parameter type is incompatible. The property form is checked contravariantly. Auto-fixable. - Boolean naming —
@typescript-eslint/naming-conventionrequires boolean type-properties and variables to be prefixed withis/has/can/should/will/did/show/hide. - Stricter TS/core —
@typescript-eslint/no-unnecessary-condition,no-use-before-define,no-shadow;no-console,complexity,eqeqeq,curly,radix,no-await-in-loop, and more, all as errors.
Overrides relax rules where appropriate: **/*.d.ts turns off naming-convention; test/spec/story files and tests/__tests__ dirs turn off import/no-extraneous-dependencies.
API
Single entry point — the exports map exposes only ., so import everything from @uxf/eslint-config:
import uxfConfig, { RESTRICTED_IMPORT_PATHS } from "@uxf/eslint-config";| Export | Type | Description |
| ------------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| default | Linter.Config[] | The full flat-config array. Spread it into your config (...uxfConfig). |
| RESTRICTED_IMPORT_PATHS | { name: string; importNames: string[]; message: string }[] | The no-restricted-imports paths list used by the shared config (currently blocks FC and PropsWithChildren from react). |
Because flat-config rule values replace rather than merge, re-declaring no-restricted-imports in a project override drops the shared paths unless you spread RESTRICTED_IMPORT_PATHS back in:
import uxfConfig, { RESTRICTED_IMPORT_PATHS } from "@uxf/eslint-config";
export default [
...uxfConfig,
{
rules: {
"no-restricted-imports": [
"warn",
{ paths: [...RESTRICTED_IMPORT_PATHS, { name: "…", importNames: ["…"], message: "…" }] },
],
},
},
];CI behavior
Some rules are demoted from a hard error to a local-only warning: they are set to warn normally and off when the CI environment variable is set (process.env.CI is truthy). This gives feedback during local development without blocking CI pipelines.
Affected rules: no-restricted-imports, no-restricted-syntax, @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, import/no-deprecated, and the React Compiler rules of react-hooks listed above.
linterOptions.reportUnusedDisableDirectives uses the same value — unused eslint-disable comments are warned locally but ignored in CI.
TypeScript 7
TypeScript 7 (the Go rewrite) ships no JavaScript compiler API yet — typescript@7 only exports lib/version.cjs plus the unstable/* entry points. typescript-eslint needs the old API, so on a TS 7 project it crashes on startup:
TypeError: Cannot read properties of undefined (reading 'Cjs')
at .../@typescript-eslint/typescript-estree/dist/create-program/shared.jsThis package solves it for every consumer: typescript is a direct dependency pinned to the last JS-API release (6.0.3), not a peer. The package manager therefore installs it into node_modules/@uxf/eslint-config/node_modules/typescript and — because the peer requirement of typescript-eslint is met from here, not from the project root — nests the whole @typescript-eslint/* tree next to it. Linting (including type-aware rules) runs on TS 6, while the project root keeps whatever typescript it wants and tsc / next build run on TS 7.
Consequences to be aware of:
- A project needs no
resolutions/overridesentry — and none would help anyway:typescriptis a peer dependency oftypescript-eslint, and Yarnresolutionscannot override how a peer is provided. - Type-aware rules always evaluate types with TS 6.0.3, independent of the project's own TypeScript. On a project whose
tsconfig.jsonuses compiler options that TS 6 removed,parserOptions.projectwill report them. - Every consumer installs a second TypeScript copy (~6 MB download).
- Yarn only. Yarn's peer-aware linker nests the whole chain (
typescript-eslint,@typescript-eslint/*,ts-api-utils) next to the bundled TypeScript. npm hoiststs-api-utilsto the project root, where it picks up the root TypeScript again and crashes withCannot read properties of undefined (reading 'Intrinsic'); anoverridesentry does not move it. An npm project therefore still needstypescript5.x/6.x at its root.
Once typescript-eslint supports TS 7 (its peer range is still >=4.8.4 <6.1.0), drop the typescript dependency and restore it as a peer.
Gotchas
- ESLint 9 flat config only. No
.eslintrc/.eslintignore; put ignores in an{ ignores: [...] }config object. - ESM package. Consume it from
eslint.config.mjs(oreslint.config.jsin an ESM project). - Keep
eslint-import-resolver-typescriptat the project root as a direct devDependency, oreslint-plugin-importfails to resolve it (seeMIGRATION.mdtroubleshooting). - Do not register
eslint-plugin-reactagain (e.g.reactPlugin.configs.flat["jsx-runtime"]or a config that bundles it) next to this config. The shared config registers its own copy of the plugin underreact, extended by the ref exception, and ESLint refuses two different objects under one plugin name (Cannot redefine plugin "react"). Copy the rules you need instead. - Do not add
importPlugin.flatConfigs.typescripton top of this config — itsnoderesolver overrides the TypeScript resolver and causesimport/no-extraneous-dependenciesfalse positives for workspace packages. - Do not re-add
settings["import/parsers"]in a project override. It names the parser as a string, whicheslint-module-utilsresolves from the project root, where@typescript-eslint/parserno longer lives; every file then reportsParse errors in imported module … Cannot find module '@typescript-eslint/parser'. Without the settingeslint-plugin-importfalls back to the parser object fromlanguageOptions.
Links
MIGRATION.md— ESLint 8 → 9 flat-config migration guide (breaking changes, step-by-step, troubleshooting).- Repository
