Step 09 of 21
Modules & tooling
Split code into ES modules, manage dependencies with npm, and build and serve a modern project with Vite.
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.
// 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)
}// 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
thisat the top level isundefined. - Imports are static.
importdeclarations 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.jsfrom 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":
<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:
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.
// 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:
npm init -y # create package.json
npm install date-fns # runtime dependency -> "dependencies"
npm install -D vitest # build/test tool -> "devDependencies"{
"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:
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev 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:
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 pageConfiguration lives in vite.config.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_:
# .env
VITE_API_URL=https://api.example.com
DATABASE_PASSWORD=hunter2const apiUrl = import.meta.env.VITE_API_URL // "https://api.example.com"
const isDev = import.meta.env.DEV // true under `vite`, false in a buildDATABASE_PASSWORD is not exposed, and that prefix rule is a safety net, not a vault.
Pitfalls you will hit
process.envis undefined in the browser. That is a Node.js global. In Vite code, useimport.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
tscfirst (tsc && vite build, ortsc -b && vite buildin the React template), and why your editor and CI must runtsc. - Default versus named import mix-ups.
import format from 'date-fns'givesundefinedor an error becausedate-fnshas no default export; you needimport { format } from 'date-fns'. - Circular imports. If
a.jsimportsb.jsandb.jsimportsa.js, one of them runs before the other's exports are initialised, producing confusingReferenceErrors. 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 independencies.
Practice
Create a vanilla TypeScript project with npm create vite@latest practice -- --template vanilla-ts. Then:
- Install
date-fnsand createsrc/time.tsthat exports a named functionago(date: Date): stringreturning text like "3 days ago". - Create
src/celebrate.tsexportingcelebrate(el: HTMLElement), which sets the element's text to "Saved!". Load it with a dynamicimport()only when a button is clicked. - Add a
VITE_APP_NAMEvariable in.envand render it in a heading. - Run
npm run buildand look atdist/assets. How many JavaScript files are there, and why?
Show solution
// src/time.ts
import { formatDistanceToNow } from 'date-fns'
export function ago(date: Date): string {
return formatDistanceToNow(date, { addSuffix: true })
}// src/celebrate.ts
export function celebrate(el: HTMLElement) {
el.textContent = 'Saved!'
}// 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')!)
})# .env
VITE_APP_NAME=Practice appInside 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
exportandimport, and load code on demand withimport(). package.jsonlists version ranges; the committed lockfile pins exact versions, andnpm cireproduces 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 throughimport.meta.env, and anything in the bundle is public. - Vite strips types without checking them, so run
tscseparately.
Check yourself
Quiz
0/4 correct · 4 left
1.Your package.json lists "react-router": "^7.2.1". Which versions can npm install for that range?
2.A plain HTML page with no build tool contains <script type="module">import { nanoid } from 'nanoid'</script>. Why does it fail?
3.How does `npm ci` differ from `npm install`?
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.