Makefiles: The Ancient Tool You Should Still Be Using

2 min read

Stop burying your build logic in package.json scripts. Discover why Makefiles remain the superior, self-documenting choice for modern task orchestration in 2026.

Share this post:
Summarize with:

Here’s the reality: your package.json scripts are a mess.

I’ve seen it a hundred times. A team starts with a simple npm start. Six months later, it’s npm run build:prod calling npm run lint calling npm run test:unit, all wrapped in a shell script that nobody remembers how to execute.

We’ve normalized this complexity. We’ve accepted that our task runners should be buried inside a JSON file that doesn’t support comments, variables, or basic logic.

Stop it. The solution has existed since 1976. It’s called make.

The Problem with npm run

JSON was never designed to be a task runner. It’s a data format. When you shove complex build logic into a "scripts" block, you’re fighting the tool.

  1. No Comments: You can’t explain why a flag is there.
  2. No Variables: You repeat paths and constants everywhere.
  3. No Dependency Tracking: This is the big one. npm scripts run blindly. They don’t know if a file actually needs to be rebuilt.

If you change one CSS file, your build script shouldn’t recompile your entire backend. But with npm run, it usually does.

Enter the Makefile

Make is simple. It maps a target to a set of dependencies and a recipe. If the dependencies haven’t changed, the target isn’t rebuilt. That’s it.

Here is a Makefile for a modern TypeScript project. Drop this in your root directory.

# Variables - define them once, use them everywhere
NODE_BIN := ./node_modules/.bin
SRC_DIR := src
DIST_DIR := dist

# .PHONY tells make these aren't real files
.PHONY: all clean test build deploy

all: install build test

# The first rule is the default.
install: package.json
	npm install
	touch install # Create a marker file to track timestamps

# Only rebuild if source files changed
build: install $(SRC_DIR)/**/*
	@echo "Building the project..."
	$(NODE_BIN)/tsc
	$(NODE_BIN)/vite build

test: install
	$(NODE_BIN)/jest

clean:
	rm -rf $(DIST_DIR) coverage

deploy: build test
	@echo "Deploying to production..."
	./scripts/deploy.sh

Why This Wins

1. It’s Self-Documenting

Run make and it does the right thing. Open the file, and you see clear targets with comments. No more hunting through a 50-line JSON object.

2. Intelligent Caching

Look at the build target. It depends on install and $(SRC_DIR)/**/*. If you run make build twice in a row without changing any source files, Make sees that the dependencies are older than the target and does nothing.

This saves seconds on every run. Across a team of 20 engineers, that’s hours per week.

3. Language Agnostic

Your backend is Go? Your frontend is React? Your infrastructure is Terraform?

Great. One Makefile handles it all.

backend:
	cd api && go build

frontend:
	cd web && npm run build

infra:
	terraform apply

Developers don’t need to know the specific incantations for every part of your stack. They just run make backend or make infra.

The “But It’s Hard” Argument

I hear this often: “Make syntax is weird. Tabs vs spaces is annoying.”

Yes, Make requires tabs for indentation. It’s a quirk from the 70s. Configure your editor to handle it and move on. The mental overhead of learning basic Make syntax is infinitesimal compared to the mental overhead of debugging a 200-character single-line shell command inside a JSON string.

Final Thought

Don’t use a hammer to drive a screw. package.json is for dependencies. Shell scripts are for execution. Makefiles are for orchestration.

Next time you find yourself chaining three npm run commands together, create a Makefile. Your future self will thank you.

Related Posts

Comments

💡 Use the Table of Contents for quick navigation through this post!