You may not need any of it
A page with <script type="module"> and a few files works today, in every browser, with no install step. Everything below exists to solve a problem — and it is worth knowing which problem, so you can tell whether you have it.
- A bundler — because hundreds of separate module requests are slow, and because
import "zod"is not a URL. - A transpiler — because you want to write syntax older browsers do not have.
- A package manager — because you are using code other people wrote.
- A linter and a formatter — because consistency and small mistakes.
- TypeScript — because a large codebase changes more safely with types.
npm
"type": "module"makes.jsfiles ESM. Without it Node treats them as CommonJS.^3.23.0allows 3.x but not 4.x.~3.23.0allows 3.23.x only. An exact version has no prefix.package-lock.jsonrecords the exact tree that was installed. Commit it. It is what makes a build reproducible.dependenciesship;devDependenciesdo not.
npm init -y // create package.json
npm install vite -D // a dev dependency
npm install zod // a runtime dependency
npm ci // install exactly what the lockfile says — for CI
npm run dev // run a script
npm outdated // what has newer versions
npm audit // known vulnerabilities
{
"name": "my-site",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint ."
},
"dependencies": { "zod": "^3.23.0" },
"devDependencies": { "vite": "^5.0.0" }
}
What a bundler does
It follows your imports from an entry file, produces one or a few files, and along the way: resolves bare specifiers like "zod" to real paths, splits code so a route's JavaScript loads only when needed, removes unused exports, and hashes filenames so they can be cached forever.
import { defineConfig } from "vite";
export default defineConfig({
build: { outDir: "dist", sourcemap: true },
server: { port: 5173 },
});
In development Vite does not bundle: it serves your modules natively and only transforms what it must, which is why the dev server starts instantly however large the project is.
Source maps
A source map maps the built file back to your source. Generate them, and upload them to your error reporting service — but consider not serving them publicly if the source is private.
at n (index-a3f2.js:1:24815) // the bundle
at readForm (form.js:12:22) // with a source map
Linting and formatting
- A formatter — Prettier, or Biome — decides quotes, semicolons and line breaks. It has no opinions worth arguing with. Run it on save.
- A linter — ESLint, or Biome — catches real mistakes: an unused variable, a missing
await,==, a React hook in a condition. Worth configuring. - Do not let the linter enforce formatting; the two fight. Let each do its job.
"no-unused-vars"
"eqeqeq"
"no-implicit-globals"
"require-await"
"no-floating-promises" // a promise nobody handles
TypeScript, briefly
- It catches a whole class of mistakes before the code runs, and it makes refactoring far safer.
- The types are erased at build time: nothing checks them at runtime. Data from a network or a form still has to be validated.
- It costs a build step and some ceremony. For a small page it is not worth it; past a few thousand lines with more than one person, it usually is.
- A middle path: plain JavaScript with JSDoc types, checked by
tsc --checkJs. No build step, most of the benefit.
type Door = { id: number; name: string; days: number; archived?: boolean };
function priceOf(door: Door, rate = 18): number {
return Math.round(door.days * 100 * (1 + rate / 100));
}
priceOf({ id: 1, name: "A" }); // error: 'days' is missing
Testing
Test the pure functions first — they are the cheapest to test and the ones a bug hides in longest. For the DOM, test what a person would do: find by role and text, click, assert what appears.
// sum.test.js → node --test
import { test } from "node:test";
import assert from "node:assert/strict";
import { priceOf } from "./price.js";
test("adds VAT", () => {
assert.equal(priceOf({ days: 5 }, 18), 590);
});
test("rejects a bad value", () => {
assert.throws(() => priceOf({ days: -1 }));
});
Deploying a static site
npm run buildproduces the files. Nothing else needs to run on the server.- Serve
dist/from any static host. Long cache headers on hashed filenames; no cache onindex.html. - Check the output: what is the bundle size, and what is in it? One accidental import of a date library is 200 KB.
- Test the built version, not only the dev server. They differ.
The order to add things
Start with plain files. Add a formatter the first time you argue about style. Add npm and a bundler the first time you want a package. Add a linter when more than one person is committing. Add types when you are afraid to change something.
Adding them in that order means every tool in the project is there because of a problem you actually had.