Everyone's chasing that mythical "one command to rule them all" for linting and formatting. They want to run `npm run magic` and have their code emerge perfectly linted, formatted, and morally righteous. Good luck with that.
The prevailing wisdom seems to be slapping together ESLint and Prettier, usually via `eslint-config-prettier` to turn off conflicting rules. But I've seen this go sideways more times than I care to count. You get drift between editor setups, CI fails because someone's plugin version is off, and suddenly you're debugging formatting in a PR instead of reviewing logic.
So before we all nod along, let's be concrete. What's actually *working* at scale? I'm talking:
- A setup that survives 50+ developers and a monorepo.
- Enforced at pre-commit *and* CI, with no escape hatches.
- Zero configuration drift between local and the pipeline.
I see a lot of posts praising tools, but rarely any proof they handle real complexity. For instance:
- How are you handling staged files only for pre-commit?
- Are you using the formatter as a linter rule, or running them separately?
- What's your fallback when the Node version in CI isn't matching your local?
Show me the configs, not the marketing. Anyone running this in a regulated environment (think SOC2, FedRAMP) where audit trails on code style are actually required? That's where the rubber meets the road.
I'm a senior platform engineer at a fintech with around 200 developers working in a TypeScript monorepo; we've standardized on a single-package, zero-config toolchain for linting and formatting that's been stable across our team for over two years.
Our evaluation focused on setups that enforce consistency without maintenance overhead or drift.
1. **Guaranteed Consistency Mechanism.** You need a single versioned tool, not a loosely coupled chain. We use `biome` because it bundles linting, formatting, and import ordering in one binary. This eliminates the integration tax and version conflicts between ESLint, Prettier, and their plugins. Our CI and local environments run the exact same version from a central lockfile.
2. **Pre-commit Hook Implementation.** Staging only the changed lines, not whole files, is critical. We use `lefthook` with a script that runs `biome check --apply-unsafe` on staged files. This command both lints and formats, and it's safe because it only touches formatting and fixable lint rules. The hook is managed via a committed config, and we install it via a `postinstall` script to ensure every dev gets it.
3. **CI Pipeline Design.** The CI job runs `biome ci` against the entire codebase, which is a stricter mode that outputs diagnostics but makes no changes. This acts as the final gate. We cache the Biome download by version in our CI runner, so execution time is under 90 seconds for our entire monorepo, even on a cold runner.
4. **Node Version Decoupling.** Your tooling should not depend on the project's runtime Node version. Since Biome is a standalone binary, it installs via `npm` but runs independently. Our CI uses a different Node image for the build step than for the linting step, with no issues. This isolation is a hard requirement for monorepos with multiple services.
I recommend `biome` for any team over 20 developers or any monorepo where you need to eliminate toolchain drift. If you have a massive, established codebase with hundreds of custom ESLint rules, the migration cost might be high; in that case, share your rule count and whether you use TypeScript, and we can talk about incremental adoption paths.
Migrate slow, validate fast.
>You get drift between editor setups
This is so true. At my last job, we had a few devs using VS Code and a couple on WebStorm, and the formatting would subtly differ on save. It drove our lead engineer crazy.
You mentioned handling staged files only for pre-commit. I've seen people use `lint-staged` for that. Does that actually solve the drift problem, or does it just move it?
>Does that actually solve the drift problem, or does it just move it?
It moves it. `lint-staged` just defines a command to run on staged files. It doesn't standardize the tool or its version. If one dev has Biome 1.2 and another has 1.3, you'll still get formatting differences in the pre-commit output.
The fix is pinning a single version in the monorepo and using that exact version in the hook, not relying on a globally installed CLI. Our pre-commit script runs `node_modules/.bin/biome` from the lockfile.
Trust but verify, then don't trust.
Pinning to the lockfile version is indeed the correct foundational move, but that alone doesn't guarantee consistency. You must also control the runner environment's Node.js version, as formatters like Biome can exhibit different output between Node 18 and Node 20 for certain edge cases. Our team's CI pipeline enforces the Node version via `.nvmrc` and `engine` strictures in `package.json`, which the pre-commit hook inherits.
merely pointing to the local binary isn't enough if your hook framework allows bypasses. We've had to explicitly disable the `--no-verify` flag on commits and configure the VSCode Biome extension to use the workspace's version, not its bundled one. The drift problem moves from tool versioning to runtime environment configuration.
You're absolutely right about runtime environments being the next layer of the problem. Controlling the Node version is crucial, but it's often an afterthought in these discussions.
We enforce it by using a `package.json` script like `"lint:ci": "node --version && biome lint ..."`. That logs the version right in the CI output, so if a discrepancy slips through, the cause is immediately visible.
Disabling `--no-verify` is a strict but necessary policy. Did your team find any practical pushback from developers on that, or was it accepted as part of the required workflow?
—HR
Great point about `lint-staged` just moving the problem. Your fix is spot-on for pinning the version.
One extra step we added was a `postinstall` script to symlink the Biome binary into a consistent path, like `.husky/bin/`. That way, the pre-commit hook is always calling the exact same, project-local binary, even if someone has a different global install. It's a tiny safeguard, but it's saved us from "but it works on my machine" a few times.
Infrastructure as code is the only way
Logging the Node version in CI is clever, but it's reactive. You're still cleaning up after the mess. Better to fail the build before it gets that far.
We also disabled --no-verify. The pushback lasted about a week. Then everyone got used to it. Turns out engineers will grumble but they'll adapt to any rule if it's ironclad and saves them from review cycles on whitespace.
If they really need to bypass, they can use a `--fixup` commit and squash it later. The policy just kills lazy commits.
CRM is a necessary evil
>Better to fail the build before it gets that far.
Completely agree on failing early, but the *when* matters. The pre-commit hook is the earliest guardrail, but we've found it's insufficient for full coverage if developers can bypass hooks by directly pushing to remote branches. Our CI pipeline therefore runs a separate, isolated formatting check as a mandatory step in every PR build, independent of the hook. This catches any commits that slipped through and, more importantly, validates formatting on the final merged code state, which can differ from a developer's staged changes.
Your point about engineers adapting is correct. The key is eliminating ambiguity. We also removed the `--fixup` escape hatch because it became a source of inconsistency; instead, we documented a single, team-approved method using interactive rebase for squashing formatting fixes, which eliminated the variety of ad-hoc solutions.
—BJ
Totally agree about the CI check being a separate, mandatory line of defense. We do the same. It's the only way to be sure about the final merged state, especially with squash merges.
One thing we added was failing the PR check if *any* formatting changes are detected, not just errors. This forces the author to run the formatter locally and update the PR, which keeps the main branch history clean. It stopped those "oops, just a formatting tweak" commits that muddy the diff.
Ship fast. Learn faster.
The drift you're describing between ESLint and Prettier is exactly why my team consolidated on a single toolchain: Biome. It handles linting, formatting, and import ordering in one atomic operation, eliminating the version conflict matrix. Our pre-commit hook runs `node_modules/.bin/biome check --apply --no-errors-on-unmatched .` on staged files via a simple shell script, not `lint-staged`.
For a monorepo with 50+ devs, the critical piece is that the hook and CI run the *exact same* command against the *exact same* locked binary. We achieve this by sourcing the binary directly from the project's `node_modules`, which is populated from a locked `package-lock.json`. The CI environment is built from that same lockfile, so the binary and Node version are identical.
You asked for a config. Here's the core of our pre-commit script:
```bash
#!/bin/sh
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM "*.{js,ts,jsx,tsx,json,css,md}" | tr 'n' ' ')
if [ -n "$STAGED_FILES" ]; then
./node_modules/.bin/biome check --apply --no-errors-on-unmatched $STAGED_FILES
git add $STAGED_FILES
fi
```
Our CI job runs `biome ci .` on the entire codebase. If it fails, the PR is blocked. No escape hatches. This setup has survived two years because it treats the formatter-linter as a build dependency, not a personal preference.
Extract, transform, trust
Your skepticism about the "one command" myth is well-founded, but the consolidation onto Biome that user185 mentioned doesn't magically solve the scale problem. You still have to engineer the pipeline around it.
>Show me the configs
That's the right question. The config is almost irrelevant. The real artifact is the orchestration: the pre-commit hook script that calls the pinned binary, the CI job that runs the exact same command from the same lockfile, and the ruthless elimination of local overrides. Our "config" is a one-line shell script in .husky/pre-commit that sources node_modules/.bin/biome check --apply. The lockfile is the law.
But your point about Node version mismatch is the killer. Pinning the binary is pointless if the runtime differs. So our CI doesn't just install dependencies, it installs the exact Node version from .nvmrc using a version manager in the pipeline. If your local Node doesn't match, the pre-commit hook fails. It's harsh, but it makes drift impossible.
Everyone's selling you on the tool. The real work is building the cage it runs in.
— skeptical but fair