Code Quality: ESLint, Prettier, and Pre-commit Hooks
Why this matters
Without automated enforcement, code style reviews become noise in pull requests: reviewers spend attention on import order and semicolons instead of logic. The combination here — ESLint catching real bugs and anti-patterns, Prettier enforcing formatting, and a pre-commit hook making both run automatically — means style violations never reach CI. The reviewer sees consistently formatted code and can focus on what matters.
The module-boundary rule (no-restricted-imports on features/**) is the structural convention that prevents feature spaghetti: it's impossible to deep-import across feature directories at the lint level, before any architecture review is needed.
This recipe uses ESLint + Prettier. Not Biome.
Prerequisites: a package with package.json and tsconfig.json.
Node / library package (no React)
1. Install dev deps
pnpm add -D eslint @eslint/js typescript-eslint eslint-plugin-simple-import-sort globals prettier2. Create eslint.config.ts
ESLint 9 reads eslint.config.ts natively when the package has "type": "module" and TypeScript is available. For non-TS-aware setups, name it eslint.config.js.
parserOptions.projectService: true enables type-aware rules (like @typescript-eslint/no-deprecated) by letting ESLint find your tsconfig.json automatically.
import js from "@eslint/js";
import { defineConfig, globalIgnores } from "eslint/config";
import simpleImportSort from "eslint-plugin-simple-import-sort";
import globals from "globals";
import tseslint from "typescript-eslint";
const sortImports = {
plugins: { "simple-import-sort": simpleImportSort },
rules: {
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
},
};
export default defineConfig([
globalIgnores(["**/dist/**", "**/node_modules/**", "**/.platform/**"]),
{
files: ["**/*.{ts,tsx}"],
extends: [js.configs.recommended, tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2020,
globals: globals.node,
parser: tseslint.parser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/no-deprecated": "warn",
},
},
sortImports,
]);React app package
1. Install React-stack dev deps (in addition to the node deps above)
pnpm add -D @eslint-react/eslint-plugin eslint-plugin-react-hooks eslint-plugin-react-refresh \
@tanstack/eslint-plugin-query @tanstack/eslint-plugin-router2. Create eslint.config.ts
The key additions beyond the node config:
@eslint-reactfor component-level rulesreact-hooksto catch violations of hook rules that React's runtime only reports at runtimereact-refreshto catch exports that would break HMR- TanStack Query and Router plugins to catch common API misuse statically
import js from "@eslint/js";
import eslintReact from "@eslint-react/eslint-plugin";
import pluginQuery from "@tanstack/eslint-plugin-query";
import pluginRouter from "@tanstack/eslint-plugin-router";
import { defineConfig, globalIgnores } from "eslint/config";
import reactHooks from "eslint-plugin-react-hooks";
import { reactRefresh } from "eslint-plugin-react-refresh";
import simpleImportSort from "eslint-plugin-simple-import-sort";
import globals from "globals";
import tseslint from "typescript-eslint";
const sortImports = {
plugins: { "simple-import-sort": simpleImportSort },
rules: {
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
},
};
export default defineConfig([
globalIgnores(["dist", "routeTree.gen.ts", "mockServiceWorker.js", ".output"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite({
extraHOCs: ["createFileRoute", "createRootRouteWithContext"],
}),
eslintReact.configs["recommended-typescript"],
pluginRouter.configs["flat/recommended"],
pluginQuery.configs["flat/recommended"],
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parser: tseslint.parser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"react-hooks/incompatible-library": "off",
"@tanstack/query/exhaustive-deps": "off",
},
},
// Module-boundary rule: code in features/** and shared/** must not deep-import another feature.
// This is enforced statically at lint time — architecture violations show up immediately, not in review.
// Pair with a `~/*` → `./src/*` path alias in tsconfig.json.
{
files: ["**/features/**/*.{ts,tsx}"],
rules: { "no-restricted-imports": ["error", { patterns: ["~/features/*"] }] },
},
{
files: ["**/shared/**/*.{ts,tsx}"],
rules: { "no-restricted-imports": ["error", { patterns: ["~/features/*"] }] },
},
// UI libs intentionally re-export many components — relax react-refresh for them
{
files: ["libs/ui/**/*.{ts,tsx}"],
rules: {
"react-refresh/only-export-components": "off",
"@eslint-react/no-array-index-key": "off",
},
},
sortImports,
]);Prettier
1. prettier.config.js
// @ts-check
/** @type {import('prettier').Config} */
const config = {
trailingComma: "all",
plugins: ["prettier-plugin-tailwindcss"],
tailwindStylesheet: "./src/styles.css",
tailwindFunctions: ["cva", "cn"],
};
export default config;For a non-Tailwind package, drop the plugins and tailwind* keys and keep trailingComma: "all" as the only override from Prettier defaults.
2. .prettierignore
# Dependency Lock Files
package-lock.json
pnpm-lock.yaml
pnpm-workspace.yaml
yarn.lock
bun.lock
# Generated Files
routeTree.gen.ts
generated.ts
distScripts
Convention: <verb> auto-fixes; <verb>check is the read-only CI gate. This separates "fix my code" (developer workflow) from "verify nothing is wrong" (CI gate).
Minimal app:
"scripts": {
"lint": "eslint . --fix",
"format": "prettier . --write",
"check": "pnpm run lint && pnpm run format"
}Full CI-grade set — separates fix from check for each tool:
"scripts": {
"lint": "eslint . --fix",
"lintcheck": "eslint .",
"format": "prettier . --write",
"formatcheck": "prettier . --check",
"typecheck": "tsc --noEmit",
"fix": "pnpm run lint && pnpm run format",
"check": "pnpm run typecheck && pnpm run lintcheck && pnpm run formatcheck"
}At the workspace root, run ESLint and Prettier across everything, and fan out type-checking to each package:
"scripts": {
"lint": "eslint . --fix",
"format": "prettier . --write",
"type-check": "pnpm -r type-check"
}Pre-commit hooks: husky + lint-staged
Why pre-commit hooks instead of relying on CI
CI catches violations after the push — the feedback loop is slow. A pre-commit hook catches violations before the commit exists, giving immediate feedback. With lint-staged, only staged files are checked, so even large repos stay fast.
1. Install
pnpm add -Dw husky lint-staged
pnpm exec husky inithusky init creates .husky/pre-commit with a placeholder. Replace it:
# .husky/pre-commit
pnpm exec lint-staged2. Configure lint-staged in the root package.json
"lint-staged": {
"*.{ts,tsx,js,jsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,yml,yaml}": [
"prettier --write"
]
}lint-staged passes only the staged files to each command — fast even in large repos. Commands run sequentially per glob; a failure aborts the commit and leaves the working tree intact so you can fix and re-stage.
3. Verify
pnpm run lint # auto-fixes import order and lint issues across the whole package
pnpm run format # rewrites files to Prettier style
git commit -m "test" # triggers lint-staged on staged files onlyTo confirm simple-import-sort fires: deliberately mis-order two imports, stage the file, and run pnpm run lint. To confirm the hook works: make a staged change with mis-ordered imports and attempt git commit — the commit should be aborted with the lint error.
Conventions to keep
- Use ESLint flat config (
eslint.config.ts) withparserOptions.projectService: truefor type-aware rules. simple-import-sortis always enabled as"error"— import order is not a style choice.- The module-boundary rule (
no-restricted-importsonfeatures/**andshared/**) is the structural convention that prevents feature coupling. Don't remove it; adjust the paths if your feature structure differs. - Naming:
lint/formatfor fix;lintcheck/formatcheck/typecheckfor CI gates;checkfor the full CI gate bundle. - husky + lint-staged at the workspace root catches issues before they reach CI.