← Back to DevBytes

Lint-Staged: Complete Configuration Guide

What is lint-staged?

Lint-staged is a tool that runs linters and formatters only on files that are currently staged in Git. Instead of linting your entire codebase every time you commit—which becomes painfully slow as projects grow—lint-staged intelligently filters and executes commands against only the files you've changed and staged for commit. It works hand-in-hand with tools like Husky to create a powerful pre-commit workflow that catches issues before they ever reach your repository.

At its core, lint-staged uses a simple but powerful concept: it accepts a configuration object mapping glob patterns to commands, then passes the staged files matching each pattern to the corresponding command. This means you can run ESLint only on staged .js files, Prettier on staged .json and .css files, and completely ignore untouched parts of your project.

Why lint-staged matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Before lint-staged, developers commonly faced two suboptimal choices: either run linters on the entire project during a pre-commit hook (which grew slower with every new file added) or skip pre-commit linting entirely and rely on CI pipelines to catch issues (which meant feedback arrived minutes or hours after a push). lint-staged solves both problems simultaneously:

Installation and setup

Setting up lint-staged involves three steps: installing the package, configuring Husky (or another Git hook manager), and defining your lint-staged configuration. Here's the complete flow:

Step 1: Install dependencies

npm install --save-dev lint-staged husky
# or
yarn add -D lint-staged husky
# or
pnpm add -D lint-staged husky

Step 2: Initialize Husky

Husky manages Git hooks so you don't have to manually edit .git/hooks files. Initialize it and create a pre-commit hook:

npx husky init

This creates a .husky directory in your project root with a pre-commit file. Edit that file to run lint-staged:

# .husky/pre-commit
npx lint-staged

Now whenever you run git commit, Husky fires the pre-commit hook, which invokes lint-staged.

Step 3: Configure lint-staged

lint-staged reads its configuration from several possible locations, in order of priority:

The simplest approach is adding it directly to package.json:

{
  "name": "my-project",
  "version": "1.0.0",
  "lint-staged": {
    "*.js": ["eslint --fix", "prettier --write"],
    "*.{css,scss,less}": ["stylelint --fix", "prettier --write"],
    "*.{json,md,graphql}": ["prettier --write"]
  }
}

For more complex configurations, a dedicated JavaScript config file offers greater flexibility:

// lint-staged.config.js
export default {
  '*.js': ['eslint --fix', 'prettier --write'],
  '*.{css,scss,less}': ['stylelint --fix', 'prettier --write'],
  '*.{json,md,graphql}': ['prettier --write'],
};

How lint-staged works under the hood

Understanding the internal mechanics helps you write better configurations. When lint-staged runs, it performs these steps in sequence:

  1. Collect staged files: It runs git diff --staged --diff-filter=ACMR --name-only -z to get the list of staged files, filtering for Added, Copied, Modified, and Renamed files.
  2. Match against globs: Each glob pattern in your configuration is tested against the staged file list. Files that match multiple patterns are assigned to the first matching rule (order matters).
  3. Resolve commands: For each matched group, the command array is processed. lint-staged automatically appends the matched file paths to the end of each command, so you don't need to reference $FILE variables manually.
  4. Execute sequentially: Commands within a rule run sequentially in the order defined. If any command exits with a non-zero code, the entire commit is blocked.
  5. Task concurrency: By default, different rules run in parallel for speed, but you can control this behavior.

The key insight is that lint-staged appends file paths to your commands automatically. When you write "eslint --fix", what actually executes is eslint --fix file1.js file2.js. This design keeps your configuration clean and declarative.

Configuration patterns and examples

Basic single-command per pattern

The simplest configuration runs one tool per file pattern:

{
  "lint-staged": {
    "*.js": "eslint --fix",
    "*.css": "stylelint --fix",
    "*": "prettier --write --ignore-unknown"
  }
}

The catch-all "*" pattern at the end runs Prettier on any staged file not already handled by earlier patterns. The --ignore-unknown flag tells Prettier to silently skip files it doesn't support rather than erroring.

Multiple commands per pattern

When you need to run several tools on the same file type, use an array of commands. They execute in order:

{
  "lint-staged": {
    "*.{ts,tsx}": [
      "eslint --fix --max-warnings=0",
      "prettier --write",
      "jest --bail --findRelatedTests --passWithNoTests"
    ]
  }
}

Here, ESLint runs first and fixes what it can. If it exits successfully (zero warnings with --max-warnings=0), Prettier formats the files, then Jest runs only tests related to the changed files. If ESLint fails, the chain stops and the commit is blocked.

Using functions for complex logic

When your configuration needs conditional logic or dynamic command generation, a JavaScript config file with functions gives you full control:

// lint-staged.config.js
export default {
  '*.js': (stagedFiles) => {
    // Extract just the filenames without paths
    const fileNames = stagedFiles.map(f => f.split('/').pop()).join(',');
    
    console.log(`Linting ${stagedFiles.length} files: ${fileNames}`);
    
    const commands = ['eslint --fix'];
    
    // Only run tests if there are more than 2 files changed
    if (stagedFiles.length > 2) {
      commands.push('jest --bail --findRelatedTests');
    }
    
    return commands;
  },
  '*.{json,md}': 'prettier --write',
};

Functions receive an array of absolute file paths and can return a string, an array of strings, or even null to skip linting for that pattern. This pattern is powerful for logging, conditional test running, or integrating with custom scripts.

TypeScript-specific configuration

TypeScript projects benefit from running both ESLint and the TypeScript compiler on staged files:

{
  "lint-staged": {
    "*.{ts,tsx}": [
      "eslint --fix",
      "prettier --write",
      "tsc --noEmit --pretty"
    ],
    "*.{js,jsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md,yml,yaml}": [
      "prettier --write"
    ]
  }
}

Note that tsc --noEmit checks types across the entire project (not just staged files) because type checking is inherently project-wide. If this is too slow for a pre-commit hook, consider moving type checking to CI and keeping lint-staged focused on faster checks. You can also use the function pattern to conditionally skip tsc:

// lint-staged.config.js
export default {
  '*.{ts,tsx}': (files) => {
    const commands = ['eslint --fix', 'prettier --write'];
    // Only run tsc when more than 5 TS files changed
    if (files.length > 5) {
      commands.push('tsc --noEmit --pretty');
    }
    return commands;
  },
};

Monorepo configuration

In monorepos using tools like Turborepo, Nx, or pnpm workspaces, lint-staged can be configured per package or globally with path-aware commands:

{
  "lint-staged": {
    "packages/web/**/*.{ts,tsx}": [
      "eslint --fix --config packages/web/.eslintrc.js",
      "prettier --write"
    ],
    "packages/api/**/*.{ts,tsx}": [
      "eslint --fix --config packages/api/.eslintrc.js",
      "prettier --write"
    ],
    "packages/shared/**/*.{ts,tsx}": [
      "eslint --fix --config packages/shared/.eslintrc.js",
      "prettier --write"
    ],
    "*.{json,md,yml}": [
      "prettier --write"
    ]
  }
}

Alternatively, use a JavaScript config to dynamically resolve the correct ESLint config based on file location:

// lint-staged.config.js
import path from 'node:path';
import fs from 'node:fs';

function findEslintConfig(filePath) {
  const dir = path.dirname(filePath);
  const configPath = path.join(dir, '.eslintrc.js');
  if (fs.existsSync(configPath)) {
    return configPath;
  }
  // Walk up until we find a config or hit the root
  const parts = dir.split(path.sep);
  while (parts.length > 1) {
    parts.pop();
    const checkPath = path.join(parts.join(path.sep), '.eslintrc.js');
    if (fs.existsSync(checkPath)) {
      return checkPath;
    }
  }
  return '.eslintrc.js'; // fallback to root
}

export default {
  '**/*.{ts,tsx}': (files) => {
    // Group files by their ESLint config
    const groups = {};
    for (const file of files) {
      const config = findEslintConfig(file);
      if (!groups[config]) groups[config] = [];
      groups[config].push(file);
    }
    
    const commands = [];
    for (const [config, groupFiles] of Object.entries(groups)) {
      commands.push(`eslint --fix --config ${config} ${groupFiles.join(' ')}`);
    }
    commands.push('prettier --write');
    return commands;
  },
};

Advanced configuration options

The --diff-filter flag

By default, lint-staged considers files with statuses A (Added), C (Copied), M (Modified), and R (Renamed). You can override this with the --diff-filter CLI flag or in your configuration:

npx lint-staged --diff-filter "AM"

This restricts linting to only Added and Modified files, excluding Copied and Renamed files. Useful if you want to skip files that were merely moved.

Concurrency control

By default, lint-staged runs tasks concurrently using os.cpus().length as the max parallelism. You can override this:

npx lint-staged --concurrency 4

Or set it to 1 for sequential execution of all tasks, which can help with debugging:

npx lint-staged --concurrency 1

Stashing and unstashing

lint-staged automatically creates a Git stash of unstaged changes before running linters, then pops the stash afterward. This ensures that linters only see the staged changes and that unstaged work isn't accidentally committed. You can disable this behavior if needed:

npx lint-staged --no-stash

Use this with caution—without stashing, unstaged modifications to the same files will be included in what gets linted and potentially committed.

Debug mode

When things aren't working as expected, debug mode prints detailed information about what lint-staged is doing:

npx lint-staged --debug

This outputs the list of staged files, which glob patterns matched, the exact commands being executed, and their exit codes. Invaluable for troubleshooting configuration issues.

Partial files with --no-stash and --partial

For extremely large files where you only want to lint the changed portions, combine --no-stash with ESLint's built-in partial linting (if supported by your linter):

npx lint-staged --no-stash

Then in your ESLint config, ensure you're using eslint --fix which handles diffs efficiently. Most modern linters already operate fast enough on full files that partial linting isn't necessary.

Common pitfalls and solutions

Pitfall 1: Commands that don't accept file arguments

Some tools like tsc ignore file arguments and always run project-wide. lint-staged still appends file paths, which may cause warnings or unexpected behavior. Solutions:

// Correct approach for project-wide tools
export default {
  '*.{ts,tsx}': () => 'tsc --noEmit --pretty',
  // tsc ignores file arguments anyway, so the function form makes intent clear
};

Pitfall 2: Conflicting formatters

Running multiple formatters on the same files can cause conflicts. For example, ESLint's --fix and Prettier can fight over formatting rules. The standard solution is to use eslint-config-prettier to disable ESLint rules that conflict with Prettier, then run ESLint first and Prettier second:

{
  "lint-staged": {
    "*.js": [
      "eslint --fix",
      "prettier --write"
    ]
  }
}

The order matters: ESLint fixes code quality issues, then Prettier formats consistently. With eslint-config-prettier installed, ESLint won't make formatting changes that Prettier would undo.

Pitfall 3: Slow pre-commit hooks

If lint-staged takes more than 3-5 seconds, developers will start using git commit --no-verify to bypass it. To keep it fast:

{
  "lint-staged": {
    "*.js": [
      "eslint --fix --cache --max-warnings=0",
      "prettier --write"
    ]
  }
}

Pitfall 4: Windows compatibility

On Windows, glob patterns and command execution can behave differently. lint-staged uses micromatch for glob matching, which is cross-platform consistent, but shell-specific syntax in commands may fail. Use the function form with execa or ensure your commands work in both PowerShell and bash environments. If your team uses Windows, test your configuration there explicitly.

Integration with popular tools

ESLint + Prettier (the classic setup)

{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "eslint --fix --max-warnings=0",
      "prettier --write"
    ],
    "*.{json,md,css,scss,html}": [
      "prettier --write"
    ]
  }
}

This is the most common configuration. ESLint handles code quality and Prettier handles formatting. The --max-warnings=0 flag ensures even warnings block the commit.

Next.js projects

{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "next lint --fix --file",
      "prettier --write"
    ],
    "*.{css,scss}": [
      "stylelint --fix",
      "prettier --write"
    ]
  }
}

Next.js's built-in linter wraps ESLint with Next-specific rules. The --file flag tells it to lint specific files rather than the whole project.

Stylelint for CSS/SCSS

{
  "lint-staged": {
    "*.{css,scss,less}": [
      "stylelint --fix --max-warnings=0",
      "prettier --write"
    ]
  }
}

Biome (all-in-one tool)

Biome combines linting and formatting into a single tool, simplifying your configuration dramatically:

{
  "lint-staged": {
    "*.{js,jsx,ts,tsx,css,json}": [
      "biome check --apply --no-errors-on-unmatched"
    ]
  }
}

The --apply flag auto-fixes both lint and format issues, and --no-errors-on-unmatched prevents Biome from erroring on files it doesn't support.

Rust-based tools (Oxlint, Ruff for Python)

For projects using the new generation of fast Rust-based linters:

{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "oxlint --fix",
      "prettier --write"
    ],
    "*.py": [
      "ruff check --fix",
      "ruff format"
    ]
  }
}

These tools are orders of magnitude faster than their JavaScript/Python counterparts, making pre-commit hooks nearly instant.

Best practices summary

Complete example: A production-ready configuration

Here's a comprehensive lint-staged configuration suitable for a TypeScript monorepo with multiple packages, combining all the best practices discussed above:

// lint-staged.config.js
// A production-ready configuration for a TypeScript monorepo

const fs = require('node:fs');
const path = require('node:path');

/**
 * Finds the nearest package-specific ESLint config for a file.
 * Falls back to the root-level config if no package config exists.
 */
function getEslintConfigForFile(filePath) {
  const dir = path.dirname(filePath);
  const parts = dir.split(path.sep);
  
  // Check each parent directory for a package-specific config
  while (parts.length > 0) {
    const checkDir = parts.join(path.sep);
    for (const configName of ['.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json']) {
      const configPath = path.join(checkDir, configName);
      if (fs.existsSync(configPath) && checkDir !== process.cwd()) {
        return configPath;
      }
    }
    parts.pop();
  }
  
  // Fall back to root config
  return path.join(process.cwd(), '.eslintrc.js');
}

export default {
  // TypeScript and JavaScript files: ESLint first, then Prettier
  '**/*.{ts,tsx,js,jsx}': (stagedFiles) => {
    if (stagedFiles.length === 0) return [];
    
    // Group files by their nearest ESLint config
    const groups = {};
    for (const file of stagedFiles) {
      const config = getEslintConfigForFile(file);
      if (!groups[config]) groups[config] = [];
      groups[config].push(file);
    }
    
    const commands = [];
    for (const [config, files] of Object.entries(groups)) {
      const fileArgs = files.map(f => `"${f}"`).join(' ');
      commands.push(
        `eslint --fix --cache --max-warnings=0 --config "${config}" ${fileArgs}`
      );
    }
    
    // Prettier runs on all matched files regardless of grouping
    const allFiles = stagedFiles.map(f => `"${f}"`).join(' ');
    commands.push(`prettier --write --log-level=error ${allFiles}`);
    
    // Only run related tests if more than 3 files changed
    if (stagedFiles.length > 3) {
      commands.push(
        `jest --bail --findRelatedTests --passWithNoTests ${allFiles}`
      );
    }
    
    return commands;
  },
  
  // Stylesheets: Stylelint then Prettier
  '**/*.{css,scss,less,postcss}': [
    'stylelint --fix --max-warnings=0',
    'prettier --write --log-level=error',
  ],
  
  // Documentation and config files: Prettier only
  '**/*.{json,md,mdx,yml,yaml,graphql}': [
    'prettier --write --log-level=error',
  ],
  
  // HTML files
  '**/*.html': [
    'prettier --write --log-level=error',
  ],
};

This configuration demonstrates several sophisticated patterns: dynamic ESLint config resolution for monorepos, conditional test execution, proper command ordering (lint before format), the --cache flag for speed, and --max-warnings=0 for strict enforcement. The Prettier --log-level=error flag suppresses informational output, keeping the commit output clean while still showing problems.

Conclusion

lint-staged transforms the pre-commit hook from a blunt instrument into a precise, fast, and developer-friendly quality gate. By operating exclusively on staged files, it keeps execution times consistently short regardless of project size, while its flexible configuration system accommodates everything from simple single-tool setups to complex monorepo workflows with dynamic command generation. Combined with Husky for hook management, lint-staged creates an invisible safety net that catches issues before they enter your repository—without developers needing to remember which commands to run. The result is cleaner commits, fewer CI failures, and a codebase that stays consistently formatted and linted with minimal friction. Whether you're working on a solo project or coordinating a large team, investing an hour in a well-tuned lint-staged configuration pays dividends in code quality and developer experience for the entire lifetime of your project.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles