JavaScript Tooling: npm, Vite and Linting

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

Node.js 22 LTS · Vite 6

Writing code that works on your machine and shipping code that runs on someone else’s are different skills. Between them sits tooling: how dependencies are installed, how scripts are run, how mistakes are caught, and what actually gets deployed. This chapter sets up the smallest toolchain that covers all four.

Starting point

Node.js 22 LTS ships npm 10. npm create vite@latest scaffolds a project interactively; a manual start is just as good and shows what the generator hides:

mkdir my-app && cd my-app
npm init -y
npm install -D vite

package.json fields worth understanding

{
  "name": "my-app",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "lint": "eslint .",
    "format": "prettier --write ."
  },
  "dependencies": {
    "date-fns": "^4.1.0"
  },
  "devDependencies": {
    "vite": "^6.0.0"
  },
  "engines": {
    "node": ">=22.12"
  }
}
  • private: true prevents an accidental npm publish.
  • type: module makes .js files ESM. Decide per package and stay consistent.
  • dependencies vs devDependencies: libraries your code imports at runtime go in the first, tools that only build or check go in the second. A bundled frontend ships only what it imports, but on the server the distinction decides what a production install pulls in.
  • engines is advisory. Enforce it with engine-strict=true in .npmrc or a version check in CI, otherwise the field is documentation.

Versions and the lockfile

^4.1.0 allows any 4.x; ~4.1.0 allows 4.1.x; 4.1.0 is pinned.

  • package.json records ranges; package-lock.json records the resolved tree with exact versions and integrity hashes.
  • Commit the lockfile. Without it, two machines resolve different trees and “works on my machine” begins.
  • npm install may update the lockfile; npm ci installs exactly what is locked and fails if package.json and the lockfile disagree. CI and deployments use npm ci.
npm install            # add a dependency locally
npm install -D vite    # add a dev dependency
npm ci                 # clean, reproducible install
npm outdated           # what is behind
npm ls react           # who pulled this package in

Deduplication matters for more than disk space: two copies of a stateful library mean two states, which shows up as a callback that never fires.

npm scripts

npm run <name> runs a script. Without run, only start, test, stop and restart are shortcuts, so always type run.

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "check": "npm run lint && tsc --noEmit"
  }
}
  • node_modules/.bin is on PATH inside scripts, so vite works without a path.
  • npm run build -- --mode staging passes everything after -- to the script, not to npm.
  • && stops at the first failure, which is what you want for check. ; keeps going but is POSIX-only; if you need “run both, report both” on Windows too, write a small Node script.
  • Avoid rm -rf, cp, export and inline env assignment in scripts if the project must build on Windows and macOS. rimraf and cross-env exist for exactly those two cases.

npx runs a package binary without installing it globally:

npx vite --version
npx prettier --check .

Vite: development and build

npm run dev      # dev server on 5173
npm run build    # bundle into dist/
npm run preview  # serve dist/ locally
import { defineConfig } from "vite";

export default defineConfig({
  base: "/",                       // "/blog/" when deployed under a subpath
  build: {
    outDir: "dist",
    sourcemap: true,
    target: "es2022",
  },
  server: {
    port: 5173,
    proxy: {
      "/api": { target: "http://localhost:3000", changeOrigin: true },
    },
  },
});
  • Dev compiles on demand and pre-bundles bare imports, which is why the server starts in milliseconds.
  • Build uses Rollup, emits hashed asset names, and is what you deploy — dist/, not the source tree.
  • base is the single most common deployment bug: assets requested from /assets/… on a site served from /app/ all 404.
  • server.proxy only exists in dev. Production needs a real reverse proxy with the same routing.

Environment variables come from .env files and import.meta.env:

import.meta.env.MODE;       // "development" or "production"
import.meta.env.BASE_URL;   // "/" or the base path
import.meta.env.VITE_API;   // only VITE_-prefixed variables are exposed

Only VITE_-prefixed variables reach the bundle, and everything in the bundle is public. An API key placed there is a published API key; the browser devtools show it, and so does a view-source. Secret handling belongs on a server you control.

process.env does not exist in the browser. Code that works in Node and references it fails after bundling, and the error points at an unrelated line.

ESLint and Prettier do different jobs

  • ESLint finds problems: unused variables, a useEffect without dependencies, == where === was meant, a promise nobody awaits.
  • Prettier only formats: indentation, quotes, line width. It has no opinion on correctness.

Running both means turning off the formatting rules ESLint still carries, so the two never disagree:

npm install -D eslint @eslint/js prettier eslint-config-prettier
// eslint.config.js — flat config, the default since ESLint 9
import js from "@eslint/js";
import prettier from "eslint-config-prettier";

export default [
  js.configs.recommended,
  prettier,
  {
    files: ["**/*.js"],
    languageOptions: {
      ecmaVersion: 2024,
      sourceType: "module",
      globals: { window: "readonly", document: "readonly" },
    },
    rules: {
      "no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
      "no-console": "warn",
      eqeqeq: ["error", "smart"],
    },
  },
  { ignores: ["dist/", "node_modules/"] },
];

Turn on “format on save” and “fix ESLint on save” in the editor, and most of these problems never reach a commit.

Layout, ignore file, output

my-app/
  index.html
  src/
    main.js
    styles.css
  public/          # copied verbatim
  dist/            # build output, never committed
  package.json
  vite.config.js
node_modules/
dist/
.env
.env.local

After a build, open dist/ and check three things: the entry HTML points at hashed assets, the paths match where the site is actually served from, and no .env content leaked into a bundle. A green build log proves compilation, not correctness.

Dependency discipline

  • Ask first whether the package earns its place. Fifteen lines of your own code, tested, often beats a dependency with its own release cadence.
  • Check npm ls <pkg> for duplicates before debugging “my callback never runs”.
  • Run npm audit, but read the advisory before running --force: most findings are in a code path you never call, and a forced major upgrade can break more than it fixes.
  • Read the changelog before a major upgrade, then run your full check and build.
  • Prefer packages with a published provenance and few transitive dependencies; you are trusting the whole tree.

Common mistakes

1. Deploying with npm install

The usual source of “it worked yesterday”. CI and deployments run npm ci.

2. Committing node_modules

Thousands of files, platform-specific binaries, and still no reproducibility. The lockfile is what makes installs identical.

3. Git-ignoring the lockfile

Every teammate resolves a different tree and the bug cannot be reproduced. Lockfiles belong in version control — for applications always, for libraries too.

4. Scripts that rely on a global tool

command not found on the next machine. Install the tool as a devDependency and call it through npm run.

5. A base that does not match the deployment path

base: "/" with the site at https://example.com/app/ produces 404s for every JS and CSS file. Check the Network panel before touching the code.

6. Secrets in VITE_ variables

They are compiled into the bundle as plain text. Anything a browser can read is public.

7. Running lint but never build

Type and import errors surface during the bundle step. CI needs both commands; a passing linter is not a passing build.

Summary

  • package.json declares, the lockfile pins, npm ci reproduces.
  • Scripts belong in npm run, tools in devDependencies; never assume a global install.
  • Vite compiles on demand in dev and bundles into dist/ for production; base breaks deployments more than any other option.
  • ESLint finds problems, Prettier formats, eslint-config-prettier stops them fighting.
  • Frontend environment variables are public; secrets stay on the server.

Next: build a real command-line tool with the pieces from this series.