Step 09 of 21

Modules & tooling

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

16 min 8 sections

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:

RangeAllowsMeaning
4.1.0exactly 4.1.0pinned
~4.1.0>=4.1.0 <4.2.0patches only
^4.1.0>=4.1.0 <5.0.0minors and patches (the default)
^0.4.2>=0.4.2 <0.5.0below 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:

ProblemWhat the build tool does
import { format } from 'date-fns' is a bare specifier the browser cannot resolveResolves it to a file in node_modules
The browser discovers imports one level at a time, creating a waterfall of requestsBundles 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 whitespaceMinifies it
Unused exports from big libraries waste bytesTree-shakes them away, using the static import structure
Users' browsers cache old files after a deployAdds 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.

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 ReferenceErrors. 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.

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?
Show 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.

Check yourself

Quiz

0/4 correct · 4 left

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

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

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

  4. 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?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.