This guide outlines how to migrate to ESLint v9, integrate Prettier for code formatting, and leverage lint-staged to automate these checks before each commit.
- Why This Migration?
- Step 1: Update Dependencies
- Step 2: Configure ESLint
- Step 3: Configure Prettier
- Step 4: Configure lint-staged
- Step 5: Update Scripts
- Step 6: Set Up Git Hooks
- Step 7: Set Up Commitlint
- Step 8: Test Configuration
ESLint v9 Migration: ESLint v9 introduces the new flat configuration format, which is simpler, more performant. The legacy .eslintrc format is deprecated and will be removed in future versions. Previously with ESLint v8, we followed Airbnb rules, but Airbnb rules are now deprecated, so we've removed Airbnb from our ESLint configuration.
Prettier Integration: While ESLint can handle code formatting, Prettier is purpose-built for code formatting with superior algorithms and broader language support. We disable ESLint's stylistic rules and let Prettier handle all formatting concerns.
lint-staged: Running linters on entire codebases during commits can be slow. lint-staged only runs ESLint and Prettier on staged files, making commits faster while maintaining code quality.
Run the following command to update/install:
npm install --save-dev prettier lint-staged @eslint/eslintrc @eslint/js @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint eslint-config-next eslint-config-prettier eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-prettier eslint-plugin-react eslint-plugin-react-hooks @commitlint/cli @commitlint/config-conventionalNote: Remove previously installed airbnb related packages.
Migrate your ESLint configuration to the flat config format (eslint.config.mjs). This is the recommended format for ESLint v9.
Create eslint.config.mjs file in your project root:
import js from '@eslint/js'
import tsParser from '@typescript-eslint/parser'
import globals from 'globals'
import { FlatCompat } from '@eslint/eslintrc'
import prettier from 'eslint-plugin-prettier'
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
recommendedConfig: js.configs.recommended,
})
const eslintconfig = [
// Global ignores
{
ignores: [
'**/.eslintrc.json',
'**/tsconfig.json',
'**/index.d.ts',
'**/*.config.js',
'.next/',
'out/',
'node_modules/',
'public/',
'scripts/update-version.js',
'./next-env.d.ts',
'coverage/',
'__tests__/',
'redux/',
],
},
// Base JS configuration
js.configs.recommended,
// Prettier integration
{
plugins: {
prettier, // Enable Prettier as an ESLint plugin
},
rules: {
'prettier/prettier': 'error', // Run Prettier as an ESLint rule
},
},
// Next.js and other plugins using compat
...compat.extends(
'next/core-web-vitals',
'next/typescript',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended',
'plugin:import/recommended',
'plugin:prettier/recommended'
),
// Main configuration for all files
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
ecmaVersion: 2020,
sourceType: 'module',
},
settings: {
react: {
version: 'detect',
},
},
rules: {
// Your existing rules (keep non-stylistic ones)
'react/prop-types': 'off',
'react/forbid-prop-types': 'error',
'react/default-props-match-prop-types': 'error',
'react/self-closing-comp': 'error',
'react/no-unused-prop-types': 'error',
'react/jsx-key': 'error',
'react/no-unused-state': 'error',
'react/state-in-constructor': 'error',
'react/function-component-definition': 'off',
'react/require-default-props': 'off',
'react/no-array-index-key': 'off',
'react/no-unescaped-entities': 'off',
'react/no-unstable-nested-components': 'off',
'react/no-danger': 'off',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
'jsx-a11y/img-redundant-alt': 'error',
'jsx-a11y/anchor-is-valid': 'error',
'jsx-a11y/alt-text': 'error',
'jsx-a11y/mouse-events-have-key-events': 'error',
'jsx-a11y/no-static-element-interactions': 'off',
'jsx-a11y/no-noninteractive-element-interactions': 'off',
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/interactive-supports-focus': 'off',
'import/first': 'error',
'import/no-mutable-exports': 'error',
'import/no-useless-path-segments': 'error',
'import/no-named-as-default': 'error',
'import/no-duplicates': 'error',
'import/newline-after-import': 'error',
'import/no-extraneous-dependencies': 'off',
'import/order': 'off',
'import/named': 'off',
'import/no-cycle': 'off',
'import/extensions': 'off',
'no-console': ['error', { allow: ['warn', 'error'] }],
'no-bitwise': 'off',
'no-underscore-dangle': 'off',
'no-nested-ternary': 'off',
'no-restricted-syntax': 'off',
'no-unused-vars': 'off',
'no-return-await': 'off',
// Remove stylistic rules (handled by Prettier)
'react/jsx-indent-props': 'off',
'react/jsx-curly-newline': 'off',
'react/jsx-equals-spacing': 'off',
'react/jsx-indent': 'off',
'react/jsx-props-no-multi-spaces': 'off',
'react/jsx-curly-brace-presence': 'off',
'react/jsx-closing-bracket-location': 'off',
'react/jsx-tag-spacing': 'off',
'react/jsx-props-no-spreading': 'off',
'react/react-in-jsx-scope': 'off',
'react/jsx-closing-tag-location': 'off',
'react/jsx-boolean-value': 'off',
'react/jsx-wrap-multilines': 'off',
'react/jsx-no-useless-fragment': 'off',
'react/jsx-one-expression-per-line': 'off',
'react/jsx-max-props-per-line': 'off',
'react/jsx-curly-spacing': 'off',
'object-curly-spacing': 'off',
'space-before-blocks': 'off',
'keyword-spacing': 'off',
'comma-spacing': 'off',
'space-infix-ops': 'off',
'quote-props': 'off',
'function-paren-newline': 'off',
'function-call-argument-newline': 'off',
'spaced-comment': 'off',
'operator-linebreak': 'off',
'computed-property-spacing': 'off',
'array-callback-return': 'off',
'space-unary-ops': 'off',
'object-shorthand': 'off',
'key-spacing': 'off',
'prefer-const': 'off',
'prefer-destructuring': 'off',
'prefer-template': 'off',
'prefer-regex-literals': 'off',
'prefer-promise-reject-errors': 'off',
'guard-for-in': 'off',
'no-cond-assign': 'off',
'no-sequences': 'off',
'no-unneeded-ternary': 'off',
'no-extra-boolean-cast': 'off',
'no-lonely-if': 'off',
'no-unsafe-optional-chaining': 'off',
'no-mixed-operators': 'off',
'no-confusing-arrow': 'off',
'no-plusplus': 'off',
'no-constant-condition': 'off',
'no-floating-decimal': 'off',
'eol-last': 'off',
'array-bracket-spacing': 'off',
'space-in-parens': 'off',
'template-curly-spacing': 'off',
'no-tabs': 'off',
'dot-notation': 'off',
'implicit-arrow-linebreak': 'off',
'padded-blocks': 'off',
'no-multiple-empty-lines': 'off',
'no-multi-spaces': 'off',
'no-else-return': 'off',
indent: 'off',
'linebreak-style': 'off',
semi: 'off',
'no-param-reassign': 'off',
'jsx-quotes': 'off',
'max-len': 'off',
'object-curly-newline': 'off',
'arrow-body-style': 'off',
'arrow-parens': 'off',
'consistent-return': 'off',
eqeqeq: 'off',
'no-trailing-spaces': 'off',
'no-useless-return': 'off',
},
},
// TypeScript-specific configuration
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tsParser,
parserOptions: {
project: './tsconfig.json',
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
'@typescript-eslint/return-await': 'error',
'@typescript-eslint/restrict-plus-operands': 'error',
'@typescript-eslint/no-shadow': 'error',
'@typescript-eslint/no-misused-promises': [
'error',
{
checksVoidReturn: false,
},
],
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-unused-expressions': 'warn',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-as-const': 'warn',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/restrict-template-expressions': 'off',
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/semi': 'off',
'@typescript-eslint/comma-dangle': 'off',
'@typescript-eslint/quotes': 'off',
'@typescript-eslint/naming-convention': 'off',
'@typescript-eslint/no-var-requires': 'off',
},
},
]
export default eslintconfigInstall the Prettier extension in your editor (for VS Code: esbenp.prettier-vscode) and create .vscode/settings.json in your project root. These settings enable format on save which will automatically format the current file when you save it:
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.useFlatConfig": true,
"prettier.requireConfig": true,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}Create a .prettierrc file in your project root to configure Prettier's formatting rules:
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid",
"endOfLine": "lf",
"printWidth": 100,
"quoteProps": "as-needed",
"jsxSingleQuote": true
}
Also, create a .prettierignore file in your project root to specify files and directories that Prettier should ignore:
node_modules/
nginx/
.next/
out/
public/
scripts/update-version.js
next-env.d.ts
coverage/
__tests__/
redux/
*.config.js
tsconfig.json
.eslintrc.json
index.d.ts
CHANGELOG.md
Configure lint-staged in your package.json to run ESLint and Prettier on staged files before committing:
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"]
}
}Add these scripts to your package.json:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint:cache": "eslint . --cache",
"lint:fix:cache": "eslint . --fix --cache",
"check-types": "tsc --pretty --noEmit",
"test": "vitest",
"test:coverage": "vitest run --coverage",
"test-all": "npm run lint && npm run check-types && npm run build",
"postinstall": "npx next telemetry disable"
}
}Install Husky and initialize git hooks:
npm install --save-dev husky
npx husky initCreate the .husky/pre-commit file with the following content:
# Get the branch name
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)
# This script runs linting, type checking, and builds before allowing a commit.
echo 'π οΈ Preparing to commit: Running linting, type checking, test coverage, and build'
# Run lint-staged for ESLint/Prettier fixes on staged files
echo "π Running lint-staged (ESLint/Prettier)..."
npx lint-staged ||
(
echo 'β Lint-staged failed. Please fix ESLint/Prettier issues and try committing again.'
exit 1
)
# Run TypeScript type checking
echo "π Running TypeScript type checking..."
npm run check-types ||
(
echo 'β TypeScript type checking failed. Please fix the type errors before committing.'
exit 1
)
# Run test coverage
# echo "π§ͺ Running test coverage..."
# npm run test:coverage ||
# (
# echo 'β Test coverage failed. Please ensure all tests pass and coverage thresholds are met before committing.'
# exit 1
# )
# Run build only for main branch
if [ "$BRANCH_NAME" = "main" ]; then
echo "ποΈ Running build for main branch..."
npm run build ||
(
echo 'β Build failed. Please ensure the build process completes successfully before committing.'
exit 1
)
fi
# All checks passed, allow the commit
echo 'β
All pre-commit checks passed. You may now commit your changes.'Create the .husky/commit-msg file with the following content:
echo "Received commit message: $1"
NAME=$(git config user.name)
EMAIL=$(git config user.email)
if [ -z "$NAME" ]; then
echo "empty git config user.name"
exit 1
fi
if [ -z "$EMAIL" ]; then
echo "empty git config user.email"
exit 1
fi
git interpret-trailers --if-exists doNothing --trailer \
"Signed-off-by: $NAME <$EMAIL>" \
--in-place "$1"
npm exec --no -- commitlint --edit $1Make both hook executable:
chmod +x .husky/pre-commit
chmod +x .husky/commit-msgCreate the configuration in your project root:
Create commitlint.config.js:
module.exports = {
extends: ['@commitlint/config-conventional'],
}Test the setup following the package.json scripts pattern:
First, let's test that commitlint is working by trying to commit with an invalid commit message. This should fail and show you that commitlint is properly configured.
-
Stage your change: Make a small change to any file then stage your change
git add . -
Try to commit with an invalid message (this should fail):
git commit -m "invalid commit message"You should see an error message from commitlint indicating that the commit message doesn't follow the conventional commit format.
Run the linting script:
npm run lintThis should run ESLint and show any linting issues.
Run TypeScript type checking:
npm run check-typesThis should run tsc --pretty --noEmit and show any type errors.
Now try with a valid conventional commit message:
git commit -m "feat: add test comment for validation"Finally, run the comprehensive test script:
npm run test-allThis runs npm run lint && npm run check-types && npm run build to ensure everything works together.