# Modules & tooling

> Split code into ES modules, manage dependencies with npm, and build and serve a modern project with Vite.

Frontend Developer roadmap · JavaScript · 16 min read · https://levelcode.org/roadmaps/frontend/modules-and-tooling

A real frontend project is hundreds of files and dozens of third-party packages, not one `script.js`. ES modules let you split code into files with explicit inputs and outputs, npm lets you install and pin other people's code, and a build tool like Vite turns all of it into fast files a browser can load. In this lesson you will learn how each piece works and how they fit together, so the commands you type stop being magic.

## ES modules: one file, one scope

Every file loaded as a module gets its own top-level scope. Nothing leaks into the global object, and nothing from another file is visible unless it is explicitly **exported** by that file and **imported** by yours.

```js
// src/money.js
export const CURRENCY = 'EUR'

export function formatPrice(cents) {
  return new Intl.NumberFormat('en-IE', { style: 'currency', currency: CURRENCY }).format(cents / 100)
}

export default function sum(values) {
  return values.reduce((total, v) => total + v, 0)
}
```

```js
// src/main.js
import sum, { formatPrice, CURRENCY as currency } from './money.js'
import * as money from './money.js'

console.log(formatPrice(sum([1999, 500])))  // "€24.99"
console.log(currency, money.CURRENCY)       // "EUR EUR"
```

A module can have many **named exports** and at most one **default export**. Named imports must match the exported name (you can rename with `as`); a default import can be given any name. Most teams prefer named exports because the name is consistent everywhere and editors can auto-import it reliably.

Modules also behave differently from classic scripts in a few ways you should know:

- **Strict mode is always on.** Assigning to an undeclared variable throws, and `this` at the top level is `undefined`.
- **Imports are static.** `import` declarations must sit at the top level with a string literal path. This lets tools work out the whole dependency graph without running your code.
- **Each module runs once.** Importing `money.js` from ten files evaluates it a single time; everyone shares the same instance.
- **Imports are live, read-only bindings.** If the exporting module reassigns an exported `let`, importers see the new value, but importers cannot assign to it themselves.

### Modules in the browser

Browsers load modules natively with `type="module"`:

```html
<script type="module" src="/src/main.js"></script>
```

Module scripts are deferred automatically: they download in parallel and run after the HTML is parsed. Two rules trip people up. First, the browser does no guessing, so `import './money'` without the `.js` extension fails. Second, modules are fetched with CORS rules, so opening the file directly from disk (`file://`) does not work; you need a local server.

### Dynamic import

When code is only needed sometimes, load it on demand with `import()`, which returns a promise of the module:

```js
button.addEventListener('click', async () => {
  const { openEditor } = await import('./editor.js')
  openEditor()
})
```

Modules may also use `await` at the top level, which pauses the importing modules until it settles. Use it sparingly, since it delays everything that depends on that module.

## CommonJS, and why you still see it

Before ES modules existed, Node.js used **CommonJS**: `require()` to import and `module.exports` to export. You will meet it in older packages, config files and Stack Overflow answers.

```js
// CommonJS
const path = require('node:path')
module.exports = { resolveAsset }
```

Node decides how to treat a `.js` file from the nearest `package.json`: `"type": "module"` means ES modules, anything else means CommonJS. (Recent Node versions will notice `import`/`export` syntax in such a file and run it as an ES module anyway, with a warning, but don't rely on that guess.) The extensions `.mjs` and `.cjs` force one or the other regardless. ES modules can import CommonJS packages, and recent Node versions can `require()` ES modules too, but for new frontend code you should write ES modules everywhere.

## npm: packages, versions and the lockfile

npm is two things: a public registry of packages and a command-line tool that installs them into `node_modules`. Your project's manifest is `package.json`:

```bash
npm init -y              # create package.json
npm install date-fns     # runtime dependency -> "dependencies"
npm install -D vitest    # build/test tool    -> "devDependencies"
```

```json
{
  "name": "shop",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "test": "vitest"
  },
  "dependencies": {
    "date-fns": "^4.1.0"
  },
  "devDependencies": {
    "vitest": "^5.0.3"
  }
}
```

### Semantic versioning

Versions are `MAJOR.MINOR.PATCH`. A major bump may break your code, a minor bump adds features, a patch fixes bugs. The prefix in `package.json` is a **range**:

| Range | Allows | Meaning |
| --- | --- | --- |
| `4.1.0` | exactly 4.1.0 | pinned |
| `~4.1.0` | `>=4.1.0 <4.2.0` | patches only |
| `^4.1.0` | `>=4.1.0 <5.0.0` | minors and patches (the default) |
| `^0.4.2` | `>=0.4.2 <0.5.0` | below 1.0, caret treats the minor as breaking |

### The lockfile

A range describes what you would accept, not what you got. `package-lock.json` records the exact version of every package in the tree, including dependencies of dependencies. Commit it. Then teammates and CI install identical code.

`npm install` may update the lockfile when `package.json` changes. `npm ci` is for automation: it deletes `node_modules`, installs exactly what the lockfile says, and fails if the lockfile and `package.json` disagree. Never commit `node_modules`; it is a build artifact that `npm ci` can always recreate.

### Scripts and binaries

`npm run dev` runs the `dev` script. Inside scripts, the binaries of installed packages (from `node_modules/.bin`) are on the `PATH`, which is why `"dev": "vite"` works without a global install. To run a package binary from the terminal directly, use `npx`, as in `npx vitest run`. pnpm and Yarn are alternative clients that use the same registry and `package.json`; the concepts carry over.

## Why bundlers exist

Native modules work, so why not ship your `src` folder as-is? Because real projects hit problems the browser cannot solve alone:

| Problem | What the build tool does |
| --- | --- |
| `import { format } from 'date-fns'` is a **bare specifier** the browser cannot resolve | Resolves it to a file in `node_modules` |
| The browser discovers imports one level at a time, creating a waterfall of requests | Bundles many modules into a few files |
| Browsers cannot run TypeScript, JSX or `import './card.css'` | Transforms them into plain JavaScript and CSS |
| Source code is large and full of whitespace | Minifies it |
| Unused exports from big libraries waste bytes | **Tree-shakes** them away, using the static import structure |
| Users' browsers cache old files after a deploy | Adds content hashes to filenames, such as `index-B4kX2c9a.js` |

That last row matters more than it looks. Because the hash changes whenever the content changes, the server can tell browsers to cache assets forever; a new deploy simply references new filenames.

## Vite: a dev server and a build

Vite is the standard build tool for new frontend projects. Create a React and TypeScript project with:

```bash
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev
```

```text
  VITE ready in 312 ms

  ➜  Local:   http://localhost:5173/
```

Vite works in two modes.

**In development** (`vite`), it does not bundle your code. It serves your source files as native ES modules and transforms each one on request, stripping types and compiling JSX. Dependencies from `node_modules` are pre-bundled once into ES modules and cached, so a library made of hundreds of internal files becomes one request. When you save a file, **Hot Module Replacement** swaps just that module in the running page, often keeping component state.

**In production** (`vite build`), it bundles, tree-shakes, minifies and hashes everything into `dist/`. `vite preview` serves that folder locally so you can check the real build.

### Project anatomy

`index.html` sits at the project root and is the entry point: Vite follows its `<script type="module" src="/src/main.tsx">` to find your code. Files in `public/` are copied untouched and referenced by absolute path (`/favicon.svg`). Anything you import from `src` (images, CSS, JSON) goes through the build:

```ts
import logoUrl from './assets/logo.svg' // a URL string: hashed file in production, or a data: URL if small
import './styles.css'                   // styles injected into the page
```

Configuration lives in `vite.config.ts`:

```ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: { port: 3000 },
})
```

### Environment variables

Vite loads `.env` files and exposes variables to client code through `import.meta.env`, but only those prefixed with `VITE_`:

```bash
# .env
VITE_API_URL=https://api.example.com
DATABASE_PASSWORD=hunter2
```

```ts
const apiUrl = import.meta.env.VITE_API_URL   // "https://api.example.com"
const isDev = import.meta.env.DEV             // true under `vite`, false in a build
```

`DATABASE_PASSWORD` is not exposed, and that prefix rule is a safety net, not a vault.

> [!WARNING]
> Everything in your client bundle is public. Anyone can open DevTools and read it. Never put secrets in a `VITE_` variable; keep them on a server.

## Pitfalls you will hit

- **`process.env` is undefined in the browser.** That is a Node.js global. In Vite code, use `import.meta.env`.
- **Vite does not type-check.** It strips TypeScript types without checking them, for speed. That is why the TypeScript templates' build scripts run `tsc` first (`tsc && vite build`, or `tsc -b && vite build` in the React template), and why your editor and CI must run `tsc`.
- **Default versus named import mix-ups.** `import format from 'date-fns'` gives `undefined` or an error because `date-fns` has no default export; you need `import { format } from 'date-fns'`.
- **Circular imports.** If `a.js` imports `b.js` and `b.js` imports `a.js`, one of them runs before the other's exports are initialised, producing confusing `ReferenceError`s. Move shared code into a third module.
- **Tools in the wrong section.** Vite, TypeScript and test runners belong in `devDependencies`; the libraries your code imports at runtime belong in `dependencies`.

> [!TIP]
> When a package misbehaves, delete `node_modules` and run `npm ci`. It rules out a corrupted or stale install in seconds.

## Practice

Create a vanilla TypeScript project with `npm create vite@latest practice -- --template vanilla-ts`. Then:

1. Install `date-fns` and create `src/time.ts` that exports a named function `ago(date: Date): string` returning text like "3 days ago".
2. Create `src/celebrate.ts` exporting `celebrate(el: HTMLElement)`, which sets the element's text to "Saved!". Load it with a dynamic `import()` only when a button is clicked.
3. Add a `VITE_APP_NAME` variable in `.env` and render it in a heading.
4. Run `npm run build` and look at `dist/assets`. How many JavaScript files are there, and why?

**Solution**

```ts
// src/time.ts
import { formatDistanceToNow } from 'date-fns'

export function ago(date: Date): string {
  return formatDistanceToNow(date, { addSuffix: true })
}
```

```ts
// src/celebrate.ts
export function celebrate(el: HTMLElement) {
  el.textContent = 'Saved!'
}
```

```ts
// src/main.ts
import { ago } from './time'

const app = document.querySelector<HTMLDivElement>('#app')!
app.innerHTML = `
  <h1>${import.meta.env.VITE_APP_NAME}</h1>
  <p>Last saved ${ago(new Date(Date.now() - 3 * 86_400_000))}</p>
  <button type="button">Save</button>
  <p id="status"></p>
`

app.querySelector('button')!.addEventListener('click', async () => {
  const { celebrate } = await import('./celebrate')
  celebrate(app.querySelector<HTMLElement>('#status')!)
})
```

```bash
# .env
VITE_APP_NAME=Practice app
```

Inside `src` you can omit the `.ts` extension because Vite resolves it for you; the browser-only rule applies when there is no build tool. The build produces two JavaScript files, something like `index-Cx81Lw2a.js` and `celebrate-D9fQm3Tb.js`. The dynamic `import()` creates a split point, so `celebrate` lives in its own chunk that is only downloaded on the first click. Thanks to tree shaking, only `formatDistanceToNow` and the helpers it needs from `date-fns` end up in the main chunk.

## Key takeaways

- An ES module has its own scope; share code with `export` and `import`, and load code on demand with `import()`.
- `package.json` lists version ranges; the committed lockfile pins exact versions, and `npm ci` reproduces them.
- Bundlers resolve bare specifiers, transform TypeScript and JSX, tree-shake, minify and hash filenames for caching.
- Vite serves native modules in development for instant startup and HMR, and bundles for production with `vite build`.
- Only `VITE_`-prefixed variables reach the client through `import.meta.env`, and anything in the bundle is public.
- Vite strips types without checking them, so run `tsc` separately.

## Quiz

1. Your package.json lists "react-router": "^7.2.1". Which versions can npm install for that range?

   A. Exactly 7.2.1
   B. 7.2.1 up to, but not including, 7.3.0
   C. 7.2.1 up to, but not including, 8.0.0
   D. Any version newer than 7.2.1, including 8.x

   **Answer: C.** A caret range allows minor and patch updates within the same major version: >=7.2.1 <8.0.0. Patch-only updates (<7.3.0) would be written with a tilde, ~7.2.1.

2. A plain HTML page with no build tool contains <script type="module">import { nanoid } from 'nanoid'</script>. Why does it fail?

   A. 'nanoid' is a bare specifier, and the browser has no way to map it to a file in node_modules
   B. Module scripts cannot use named imports
   C. The browser needs the package to be listed in devDependencies
   D. Inline module scripts are not allowed

   **Answer: A.** Browsers only resolve URLs and relative or absolute paths. Mapping a package name to a file is something bundlers like Vite (or an import map) do for you.

3. How does `npm ci` differ from `npm install`?

   A. It installs only devDependencies
   B. It removes node_modules and installs exactly what the lockfile specifies, failing if package.json and the lockfile disagree
   C. It upgrades every package to the newest version allowed by its range
   D. It skips the lockfile so the install is faster

   **Answer: B.** npm ci is built for reproducible installs in CI. It never rewrites the lockfile; if the lockfile is out of sync with package.json it errors instead of 'fixing' it.

4. Your .env file contains API_URL=https://api.example.com. In a Vite app, `import.meta.env.API_URL` is undefined. What is the fix?

   A. Read it from process.env.API_URL instead
   B. Move the .env file into the public/ folder
   C. Import the .env file as a module
   D. Rename the variable to VITE_API_URL and read import.meta.env.VITE_API_URL

   **Answer: D.** Vite only exposes variables with the VITE_ prefix to client code, so that server-only values are not leaked by accident. process.env does not exist in the browser.
