Lesson 6 of 107 min read

Modern Tooling

Share:WhatsAppLinkedIn

What you'll build

By the end of this lesson you will have scaffolded a Vite project, installed dependencies, run a dev server with hot module replacement, and built a production bundle. You will understand what node_modules, package.json, and package-lock.json are and why the bundler generates a dist/ folder instead of serving your source files directly.

Concepts

What Node.js is (and what it is not)

Node.js is a JavaScript runtime that runs outside the browser, on your laptop or on a server. It uses Chrome's V8 engine to execute JavaScript, but it has access to the file system, the network, and operating system APIs that browsers intentionally block for security reasons.

When you install Node, you also get npm (Node Package Manager), which downloads open-source packages from npmjs.com.

# Check your versions
node --version    # e.g., v22.1.0
npm --version     # e.g., 10.7.0

Node is not "the backend" by itself. It is a runtime. You use it to run build tools (Vite, webpack, TypeScript compiler), CLIs (create-react-app, Prisma), and, when you choose to, servers (Express, Fastify).

package.json and dependencies

package.json is the manifest for your project. It lists the project's name, version, scripts you can run, and dependencies.

{
  "name": "my-app",
  "version": "1.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.3.0",
    "react-dom": "^18.3.0"
  },
  "devDependencies": {
    "vite": "^5.2.0"
  }
}

dependencies are packages your app needs to run in production. devDependencies are packages only needed during development (bundlers, linters, type checkers). When you deploy to a server, npm install --production skips devDependencies.

The ^ before a version number means "compatible updates are OK", npm can install 18.3.x or 18.4.x but not 19.x. The exact installed versions are locked in package-lock.json, which you should commit to version control so every developer on your team gets identical packages.

npm install lodash          # adds to dependencies
npm install vite --save-dev # adds to devDependencies
npm install                 # installs everything in package.json

Why bundlers exist

In 2010 you could serve JavaScript as individual <script> tags. As projects grew, people split code into dozens of files, imported libraries from CDNs, and quickly hit two problems:

  1. Dozens of HTTP requests. Each <script> tag is a separate request. HTTP/1.1 browsers could only handle 6 concurrent requests per domain, so 30 scripts meant several sequential round trips.
  2. No module system. There was no native way to say "this file depends on that file" without managing a manual load order.

Bundlers solve both: they read your source code, follow import statements, pull in everything from node_modules, and output one (or a few) optimised JS files ready for production. They also minify code (remove whitespace, shorten variable names), tree-shake (remove code that is never used), and generate source maps for debugging.

Modern bundlers are also dev servers, they serve your source files with hot module replacement (HMR), which updates the browser instantly when you save a file, without a full reload.

Vite

Vite (pronounced "veet", French for "fast") is the dominant modern bundler for front-end projects. During development it serves your files using native ES modules in the browser, no bundling step at all, so the dev server starts in milliseconds even in a large project. For production builds it uses Rollup under the hood to produce optimised bundles.

# Scaffold a new Vite project
npm create vite@latest my-app -- --template vanilla

cd my-app
npm install
npm run dev

The --template flag sets the starting template. Options include vanilla, vanilla-ts, react, react-ts, vue, svelte, and more.

After npm run dev, you will see:

  VITE v5.2.0  ready in 234 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose

Open http://localhost:5173. Any change to a .js or .css file in src/ updates the browser instantly.

The project structure

A freshly scaffolded vanilla Vite project looks like this:

my-app/
  index.html          ← entry point, Vite reads this
  src/
    main.js           ← your JavaScript entry
    style.css         ← your CSS entry (imported in main.js)
  public/             ← static files, copied as-is to dist/
  package.json
  package-lock.json
  node_modules/       ← never commit this
  .gitignore          ← should include node_modules/ and dist/

node_modules/ can contain tens of thousands of files and hundreds of megabytes. It is always excluded from git because anyone can recreate it with npm install from package.json.

Hands-on

Let's scaffold a project, make a change, and build it.

Step 1: Create the project.

npm create vite@latest github-finder -- --template vanilla
cd github-finder
npm install

Step 2: Start the dev server.

npm run dev

Open http://localhost:5173. You see the default Vite + Vanilla JS starter.

Step 3: Replace src/main.js with your own code.

Open src/main.js and replace everything with:

import "./style.css";

document.querySelector("#app").innerHTML = `
  <h1>GitHub Finder</h1>
  <div class="search-bar">
    <input type="text" id="username" placeholder="GitHub username" value="torvalds">
    <button id="search-btn">Search</button>
  </div>
  <div id="results"></div>
`;

const btn = document.querySelector("#search-btn");
const input = document.querySelector("#username");
const results = document.querySelector("#results");

async function search() {
  const user = input.value.trim();
  if (!user) return;
  results.textContent = "Loading...";

  try {
    const res = await fetch(`https://api.github.com/users/${user}/repos?sort=stars&per_page=6`);
    if (!res.ok) throw new Error(`${res.status}`);
    const repos = await res.json();
    results.innerHTML = repos.map(r =>
      `<p><a href="${r.html_url}" target="_blank">${r.name}</a>, ${r.stargazers_count} stars</p>`
    ).join("");
  } catch (e) {
    results.textContent = `Error: ${e.message}`;
  }
}

btn.addEventListener("click", search);
input.addEventListener("keydown", e => e.key === "Enter" && search());
search();

Save the file. The browser updates without a reload, that is HMR. The GitHub finder from lesson 5 is now running inside a proper toolchain.

Step 4: Build for production.

npm run build

This creates a dist/ folder. Look inside it:

dist/
  index.html
  assets/
    index-BxYd3j4k.js    ← your code + dependencies, minified, with a hash in the name
    index-CkLp8mNq.css   ← your CSS, minified

The hash in the filename changes whenever the file content changes, which forces browsers to download the new version instead of using a stale cached copy.

Step 5: Preview the production build locally.

npm run preview

This serves dist/ on http://localhost:4173. This is what the real server will serve after you deploy.

Common pitfalls

  • Committing node_modules/. It is huge, OS-specific, and fully regeneratable. Always have it in .gitignore. Vite's scaffolding adds it by default; check before you first push.
  • Confusing npm run dev and npm run build. dev gives you a dev server with HMR, not what you deploy. build creates the production bundle in dist/. Always deploy the build output.
  • Not reading error messages. npm errors are verbose but accurate. When npm install fails, read the last ten lines, it almost always tells you exactly what went wrong (wrong Node version, missing native dependency, etc.).
  • Mixing --save and --save-dev carelessly. Putting Vite or TypeScript in dependencies instead of devDependencies bloats your production bundle if anything bundles it transitively. Use --save-dev for tools that do not run in the browser.
  • Using npm ci vs npm install on servers. On CI/CD pipelines and servers, prefer npm ci, it installs exactly what is in package-lock.json without modifying it, which is faster and more reproducible.

What to try next

  1. Scaffold a new project with --template react and explore how Vite handles JSX. Look at the difference in main.jsx compared to main.js.
  2. Add a linter. Run npm install eslint --save-dev and then npx eslint --init. Enable the "recommended" rules and run npx eslint src/. Fix any warnings it finds.
  3. Change the port the dev server runs on. In vite.config.js (create it if it does not exist), add export default { server: { port: 3000 } }. Stop and restart the dev server and notice it now runs on port 3000.

Test Your Knowledge

Take a quick quiz on this lesson

Start Quiz →

Prefer watching over reading?

Subscribe for free.

Subscribe on YouTube