Building a Better Developer Workflow with Automation

I walk through setting up npm/bun scripts, Husky pre-commit hooks, and lint-staged so your project checks code quality on every commit without slowing down the process.

Building a Better Developer Workflow with Automation

There’s a specific frustration that shows up in every growing project. You push a change, the CI pipeline fails because a lint error wasn’t caught locally, and now you’re waiting five minutes for a build server to tell you something your linter would have flagged in under a second — if you’d remembered to run it.

The gap isn’t the tooling. Most projects already have linters, formatters, and test suites. What’s missing is the automation layer that runs those tools at the right moment: before the commit leaves your machine.

Quick answer: Install Husky with npx husky init, install lint-staged, and add npx lint-staged to .husky/pre-commit. Configure lint-staged in package.json to run your formatter and linter only against staged files. Combined with a few well-named package.json scripts, the whole setup takes under 10 minutes and every subsequent commit is automatically checked before it can complete.

On this page

Start with your package.json scripts

Package.json scripts are the simplest form of workflow automation. They give short names to commands you run repeatedly and ensure the whole team uses the same flags without having to remember them.

A useful scripts section might look like this:

{
  "scripts": {
    "dev": "node server.js",
    "build": "tsc --project tsconfig.json",
    "lint": "eslint src --ext .ts,.tsx --max-warnings 0",
    "lint:fix": "eslint src --ext .ts,.tsx --fix",
    "format": "prettier --write src",
    "test": "vitest run",
    "typecheck": "tsc --noEmit",
    "check": "npm run lint && npm run typecheck && npm run test"
  }
}

The check script is the most useful one here. Running lint, type checking, and tests with a single command means you can verify the full picture before opening a pull request without working through a mental checklist.

Two scripts worth separating: lint (report errors only) and lint:fix (auto-fix what it can). Keeping them separate makes it clear whether a passing run means “no errors found” or “errors were silently rewritten before you saw them.”

If you’re using Bun: bun run check works identically — the syntax is the same, you replace npm run with bun run.

Husky: run checks before every commit

Package.json scripts only run when you remember to run them. Git hooks change that — they fire automatically at specific points in the Git workflow, like right before a commit is created.

Husky (version 9 at time of writing) makes Git hooks easy to set up and commit to the repository so the whole team gets them automatically. Under the hood, it uses core.hooksPath to point Git at the .husky/ directory in your project. If you want a deeper look at how Git tracks and stages changes before hooks even run, Git Basics Every Developer Should Master covers that model.

Install and initialize:

npm install --save-dev husky
npx husky init

This creates .husky/pre-commit with a placeholder script and adds a prepare script to package.json:

{
  "scripts": {
    "prepare": "husky"
  }
}

The prepare script runs automatically after npm install, so a new team member who clones the repository and runs npm install gets the hooks installed without any extra steps.

Update .husky/pre-commit to run your checks:

# .husky/pre-commit
npm run lint
npm run test

Now every git commit runs lint and tests before the commit is created. If either exits with a non-zero code, the commit is aborted and the files stay staged.

Try this

  1. Run npx husky init in a project that has a lint script configured.
  2. Replace the placeholder in .husky/pre-commit with npm run lint.
  3. Stage a file with an intentional lint violation and run git commit -m "test lint hook".

Expected result: The commit is blocked. The terminal shows the lint output with the violation, and your file stays staged but uncommitted. Fix the violation, stage the fix, and the commit goes through normally.

Running the full test suite on every commit works fine when the suite is small. As the project grows past a few hundred tests, that changes.

lint-staged: check only what you’re committing

When the test suite hits a certain size, running it before every single commit can push the pre-commit hook past 30 seconds. At that point, developers start reaching for --no-verify to skip it, which defeats the purpose entirely.

lint-staged solves this by running your configured tasks only against the files you’ve staged — the ones that are actually changing in this commit. There’s no reason to lint unchanged files.

Install it:

npm install --save-dev lint-staged

Add configuration to package.json:

{
  "lint-staged": {
    "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{js,jsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md,css}": "prettier --write"
  }
}

Update .husky/pre-commit to call lint-staged instead of running the full suite:

# .husky/pre-commit
npx lint-staged

The glob patterns use picomatch syntax. A *.ts pattern matches TypeScript files in any directory. When --fix and --write make changes, lint-staged automatically re-stages those changes so the corrected version is what lands in the commit — not the version you had before the formatter ran.

Check this before moving on

  • lint-staged is listed in devDependencies in package.json
  • The glob patterns in your configuration match the file types your project actually uses
  • Running npx lint-staged --verbose manually shows tasks executing against your staged files

One thing to avoid: don’t put tsc --noEmit in the lint-staged configuration. TypeScript ignores tsconfig.json when individual file paths are passed as arguments, which makes the type check behave differently than a full tsc run. Keep type checking as a separate step after npx lint-staged in the pre-commit hook, or run it only in CI.

Shell scripts for repeated project tasks

Package.json scripts handle short commands well. Anything that needs branching logic, takes user arguments, or chains multiple operations with checks between them is cleaner as a dedicated shell script.

A scripts/ directory at the project root is a natural home. This is a pattern you’ll recognize from production project layouts:

scripts/
  seed-db.sh            # populate the development database
  reset.sh              # clear caches and restart the dev server
  create-migration.sh   # generate a timestamped migration file

Here’s a migration file generator as a practical example:

#!/bin/sh
# scripts/create-migration.sh
set -e

NAME=${1:?"usage: create-migration.sh <name>"}
TIMESTAMP=$(date +%Y%m%d%H%M%S)
FILE="migrations/${TIMESTAMP}_${NAME}.sql"

mkdir -p migrations
printf -- "-- Migration: %s\n" "${NAME}" > "$FILE"
echo "Created: $FILE"

Wire it to a package.json script so it appears in npm run and your team can discover it:

{
  "scripts": {
    "migration:create": "sh scripts/create-migration.sh"
  }
}

Now npm run migration:create add_user_preferences creates migrations/20251230143012_add_user_preferences.sql with a consistent naming convention, regardless of who runs it.

The set -e at line 3 makes the script exit immediately if any command fails. Without it, a failing mkdir or a bad variable substitution can silently let the rest of the script continue, leaving the project in a partially changed state that’s hard to diagnose.

VS Code tasks: run commands from the editor

If your team uses VS Code (or Cursor, which shares the same task format), .vscode/tasks.json wires shell commands to the editor’s task runner and keyboard shortcuts. It’s not a replacement for package.json scripts — it’s a layer on top that makes specific commands accessible without switching to a separate terminal window.

{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "Run tests (watch)",
      "type": "shell",
      "command": "npm run test:watch",
      "group": "test",
      "presentation": {
        "reveal": "always",
        "panel": "dedicated"
      }
    },
    {
      "label": "Typecheck",
      "type": "shell",
      "command": "npm run typecheck",
      "group": "build",
      "problemMatcher": "$tsc"
    }
  ]
}

The detail worth noting is "problemMatcher": "$tsc" on the typecheck task. It tells VS Code how to parse TypeScript compiler output, so type errors appear in the Problems panel with clickable file and line references instead of raw terminal text. That single property makes reviewing a batch of type errors noticeably faster.

Commit .vscode/tasks.json to the repository so the whole team gets the same tasks without any configuration.

What breaks and why

A few issues come up reliably when teams set this up for the first time.

command not found with a Node version manager. If Node is installed via nvm, fnm, or volta, hooks launched from a GUI Git client — GitHub Desktop, VS Code’s built-in source control panel, Sourcetree — often fail because those clients don’t inherit the terminal’s PATH. Husky reads ~/.config/husky/init.sh before every hook, so the fix is to add your version manager’s init code there:

# ~/.config/husky/init.sh
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"

Bypassing hooks when you have a legitimate reason. Sometimes you need to commit something the hook would reject — an incomplete work-in-progress, a bulk update that generated files. The --no-verify flag bypasses all hooks for that commit:

git commit -m "wip: in progress" --no-verify

This is an escape hatch. If the team is bypassing hooks regularly, the checks are probably too slow or checking things that don’t apply to every commit.

Hooks missing after a fresh clone. The prepare script in package.json runs automatically on npm install. If hooks still don’t appear after install, run npm run prepare once manually to trigger the Husky setup.

Husky erroring on CI. CI pipelines run the checks directly — they don’t commit code, so there’s no reason to run Git hooks there. If your CI installs only production dependencies (skipping devDependencies), the prepare script fails because Husky isn’t present. Two clean ways to handle this:

# Set this environment variable in your CI configuration
HUSKY=0

Or make prepare tolerate a missing Husky installation:

{
  "scripts": {
    "prepare": "husky || true"
  }
}

Automate the chores, protect the decisions

The principle behind all of this: automation should handle the mechanical, repeatable work — formatting, linting, import ordering — so code review can focus on what actually matters: design, correctness, and intent.

The right level of automation varies. A small solo project with a fast test suite can run everything before every commit. A larger codebase might keep the pre-commit hook to fast checks only (lint, format, typecheck) and gate CI on the full suite. Both are reasonable; the important thing is that the checks run automatically rather than on request.

One limitation worth knowing: local hooks can always be bypassed with --no-verify. They’re not a security gate — they’re a convenience. The CI pipeline is what ensures the checks actually ran before code merges. I covered building that pipeline in Building a GitHub Actions Pipeline for a Node.js App.

Sources