Chapter 7

Sass, Vite and npm

Package management, dependency integrity, modern Sass modules, and Vite's development and production workflows.

7. Sass, Vite and npm

Modern frontend applications rely on more than HTML, CSS, and JavaScript files. Node.js provides a runtime for development tools; npm installs and coordinates packages; Vite serves source modules during development and builds deployable assets; and Sass adds a stylesheet language that compiles to CSS. Together, these tools form a workflow for turning editable source into a tested, optimized application.

They are separate parts of the toolchain. Node is the runtime, npm is a package manager and registry client, Vite is a development server and build tool, and Sass is a CSS preprocessor. Knowing where each responsibility begins makes dependency errors, build failures, and environment leaks easier to diagnose.

Overview & Objectives

After this chapter, you should be able to describe how package manifests and lockfiles work, select sensible dependency ranges, use Sass modules, explain Vite's development and production paths, manage assets and environment variables safely, and investigate common build problems.

The Node.js and npm Ecosystem

Node.js runs JavaScript outside the browser. Frontend tools use it to read files, transform source code, start local servers, run tests, and generate production bundles. The application itself may run in a browser, on a server, or both; using Node for the build does not imply that browser code can access Node-only APIs such as fs or process.

npm refers to several related things: the npm command-line client, the public npm registry, and the package ecosystem. A package is a versioned unit of code with metadata and possibly dependencies, scripts, and compiled entry points. Projects may use npm with its default node_modules installation, workspaces for a monorepo, or a compatible package manager. The selected manager and lockfile should be consistent across the team and CI.

Check the required Node version before installing packages. A project's engines field, .nvmrc, .node-version, Volta configuration, or CI setup may specify it. Different Node versions can change available platform features and package compatibility. If a tool reports an unsupported engine, use the project's supported runtime rather than immediately forcing the install.

package.json: project contract

package.json describes a package or application. Important fields include:

  • name and version: package identity and release version.
  • type: how Node interprets .js files, such as ES modules.
  • scripts: named commands run with npm run.
  • dependencies: packages required by the application at runtime or in its delivered bundle.
  • devDependencies: tools needed for development, testing, or building.
  • engines: supported runtime constraints.

For example:

json
{
  "name": "field-notes",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=22"
  },
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "test": "vitest run"
  },
  "devDependencies": {
    "vite": "^8.1.0",
    "vitest": "^4.1.0"
  }
}

These versions are illustrative; use versions supported by the project. private: true prevents accidentally publishing an application package to the registry. Scripts are project interfaces: a teammate can run npm run build without memorizing the underlying CLI command. Local binaries from node_modules/.bin are placed on the script's PATH automatically.

Dependencies are not interchangeable with scripts. npm run build invokes a project tool; npm install changes the installed dependency graph. Be deliberate when adding packages: check whether a built-in platform API or existing dependency already solves the need.

Dependency Installation and Lockfile Integrity

The manifest expresses acceptable direct dependencies. A lockfile records the resolved package tree, including exact package versions and resolution metadata. npm projects typically use package-lock.json; other managers use their own format. Commit the lockfile for an application so local development and CI can install the same graph.

When a dependency is added or changed with npm, update the manifest and lockfile together using npm commands. Do not hand-edit resolved URLs, versions, or integrity values. The lockfile's integrity field is commonly a cryptographic digest of a package archive; npm uses it to check that downloaded content matches the recorded artifact. Integrity is an important consistency check, not a guarantee that a package is safe or free of malicious code.

For a clean CI install, npm ci installs from the lockfile and fails if the manifest and lockfile disagree. It removes the existing node_modules tree before installing, so it is intended for disposable CI environments and clean setups, not as a casual command while relying on local uncommitted package state. npm install is used during normal development and can update lockfile resolutions when package ranges or dependency declarations change.

Some dependencies need platform-specific optional packages or install-time scripts. A lockfile still helps make resolution consistent, but runtime, operating system, CPU architecture, npm version, and install flags can affect the final installation. Document intentional flags and use the same approach in CI.

Dependency categories

A runtime dependency is imported by the application or required when it executes. A development dependency is generally used only to author, test, lint, or bundle the app. Framework SSR deployments can have a more nuanced boundary: a package used by server code may need to be present in the production runtime even if it is absent from the browser bundle. Verify the target deployment model before moving packages between sections.

Avoid installing a package globally just to make one project command work. A local dependency pins the tool to the project, allowing teammates and CI to run the same version. Global tools can still be useful for package-manager management, but should not silently determine project builds.

Semantic Versioning: ^ and ~

Semantic versioning represents a release as MAJOR.MINOR.PATCH:

  • A major version may introduce incompatible public API changes.
  • A minor version adds backward-compatible features.
  • A patch version fixes backward-compatible defects.

Dependency ranges tell the package manager which releases it may select. Common npm range prefixes include:

  • ^1.4.2: allow compatible updates below the next major release; for a stable 1.x package, this usually means >=1.4.2 <2.0.0.
  • ~1.4.2: allow patch updates within the same minor line, usually >=1.4.2 <1.5.0.
  • 1.4.2: request that exact version.

For 0.x versions, caret ranges are more restrictive because minor releases can be breaking before 1.0.0; for example, ^0.4.2 does not mean every 0.x release. Prerelease versions also have special range rules. When a precise boundary matters, inspect the resolved range rather than relying on shorthand intuition.

The range in package.json is not the exact installed version. The lockfile records the concrete resolution for an installation. A range allows future resolution changes when the lockfile is intentionally regenerated; a committed lockfile preserves the team's currently resolved tree until it is updated.

Use ranges consistent with your release policy. Caret ranges are common for application dependencies, while tilde or exact pins may be appropriate for tools where controlled updates are preferred. Pinning a version does not remove the need to apply security and compatibility updates. Conversely, a broad range does not mean an existing locked installation silently upgrades every time; updating the lockfile is a reviewable change.

Resolving and Auditing Dependencies

The dependency tree includes direct packages and their transitive dependencies. A package can introduce another version of a library, optional dependencies, peer dependencies, platform constraints, and install scripts. Useful inspection commands include:

sh
npm ls --depth=0
npm ls some-package
npm explain some-package

npm ls displays installed packages and can show invalid or missing relationships. npm explain helps identify why a package exists in the tree. Check an installed package's documentation, maintenance activity, license, Node compatibility, browser support, and transitive footprint before adopting it.

Run npm audit to compare the resolved tree with advisories known to npm. An audit is a useful signal, not a complete security review: a clean result cannot prove that code is trustworthy, and a reported advisory does not by itself determine whether the vulnerable path is reachable in your application. Review severity, affected versions, exploit conditions, and available remediations.

npm audit fix may update packages within allowed ranges and change the lockfile. Review the resulting diff and run tests and builds. npm audit fix --force can cross declared version boundaries or make major changes; do not run it blindly. If an advisory has no safe compatible fix, document the risk, identify the affected functionality, and plan a deliberate upgrade or mitigation.

Keep dependencies current through small, reviewed changes. Broad automated updates can be useful, but the application's tests and deployment checks must verify behavior. Remove a dependency only after checking imports, scripts, build configuration, and server/runtime use.

Sass and the Modern Module System

Sass is a stylesheet language that compiles into ordinary CSS. It offers features such as variables, functions, mixins, and modules. Browsers receive CSS, not Sass source. Vite can process .scss and .sass files when a compatible Sass implementation is installed, for example sass or sass-embedded.

Recent Sass versions deprecate the old @import mechanism in favor of modules. Modules improve encapsulation, make dependencies explicit, and avoid repeatedly loading the same styles. Prefer @use and @forward for new Sass.

@use: load a module with a namespace

Suppose a project has:

scss
// styles/_tokens.scss
$brand: #174a8b;
$space-unit: 0.25rem;

@mixin focus-ring {
  outline: 2px solid $brand;
  outline-offset: 2px;
}

Load and use it with @use:

scss
// styles/components/_button.scss
@use "../tokens";

.button {
  padding: tokens.$space-unit tokens.$space-unit * 4;
  color: white;
  background: tokens.$brand;

  &:focus-visible {
    @include tokens.focus-ring;
  }
}

The namespace makes the source of a variable or mixin visible. By default, Sass derives it from the module filename. An alias can make it shorter or clearer:

scss
@use "../tokens" as theme;

.button {
  color: theme.$brand;
}

@use loads a module once per compilation even when several files use it. Its members are namespaced unless an alias such as as * is selected. Using as * removes the namespace, but can make collisions and the origin of a token harder to identify. Prefer explicit namespaces in shared code.

@forward: publish a module API

A directory can expose a deliberate public interface through a file such as _index.scss:

scss
// styles/settings/_index.scss
@forward "colors";
@forward "spacing";
@forward "mixins" show focus-ring;

Consumers can then load the directory's public API:

scss
@use "settings" as settings;

.link:focus-visible {
  @include settings.focus-ring;
}

@forward re-exports selected members so callers do not need to know the internal file layout. It can use show, hide, or as to control the API. This is useful for a design-system module; it is unnecessary ceremony for a project with only one small stylesheet.

Configuration and CSS output

A Sass variable marked !default can be overridden before the module is loaded. Configure a module with @use ... with (...):

scss
// styles/_theme.scss
$brand: #174a8b !default;
$radius: 0.5rem !default;

// styles/app.scss
@use "theme" with (
  $brand: #235a9f,
  $radius: 0.75rem
);

The configuration is applied on first load, so load order matters. Sass values are evaluated at build time and are not automatically available to JavaScript or changeable by the end user. Use CSS custom properties for values that need runtime theming, such as a light/dark mode or tenant-specific color.

Sass variables and mixins do not emit CSS by themselves. Rules and included mixins do. Keep shared modules focused on tokens and helpers; importing a large file that emits global selectors from many component stylesheets can duplicate CSS or create unexpected cascade order.

Avoid deeply nested selectors. Nesting should express a clear relationship such as a component's focus state, not mirror the entire HTML tree. Excessive nesting produces long selectors that are harder to override and test.

Vite Architecture: Development and Build

Vite provides a local development server and a production build pipeline. In development, it serves native ES modules to the browser, transforms source files as needed, and avoids bundling the whole application on every edit. Browsers request modules through the import graph; Vite can transform TypeScript, JSX, CSS, and framework files through configured plugins. Dependencies may be pre-bundled to make browser consumption more efficient and normalize packages that are not authored as browser-friendly ESM.

This is why startup and incremental edits can be fast in a large project: Vite does not need to recreate one enormous bundle for every source change. It still performs transforms, dependency optimization, and plugin work, so configuration and dependency shape can affect startup.

Hot Module Replacement

Vite's HMR updates changed modules in the browser without requiring a full page reload when the module and framework support safe replacement. A React plugin, for instance, can preserve component state for many edits. Changes to global CSS usually update styles without a reload. Some edits require a full reload if module boundaries or side effects cannot be updated safely.

HMR is a development convenience, not proof that a production build works. A stale module cache, unsupported transform, or production-only asset path can still produce deployment bugs. Reproduce important behavior through a production build and preview.

Production bundling: Vite version matters

Production builds bundle, transform, and optimize the application rather than serving each source module separately. Vite's earlier production pipeline used Rollup. The project in this course uses Vite 8, whose production bundler is Rolldown, a Rust-based bundler with a Rollup-compatible plugin and configuration model. Many Vite concepts and options remain familiar, but exact bundler behavior can differ by major version; consult the installed Vite documentation rather than assuming every older Rollup detail applies unchanged.

The output usually contains HTML, JavaScript chunks, CSS, and static assets with generated filenames. The application can use dynamic imports to split routes or features into separate chunks. The browser loads a deferred chunk when the code path is needed instead of downloading all feature code initially.

Vite configuration lives in vite.config.js or vite.config.ts and can configure plugins, aliases, CSS processing, server behavior, and build options. Keep configuration focused; prefer supported plugin APIs over custom transforms when a maintained plugin already solves the problem. If a project uses SSR or a framework integration, the framework's build adapter may add server bundles and deployment-specific output in addition to browser assets.

Asset Handling

Vite treats imported assets as part of the module graph. Importing an image, font, or other supported asset lets the build resolve its path, fingerprint it for cache-friendly output, and update references:

ts
import logoUrl from "./assets/logo.svg";

const image = document.querySelector<HTMLImageElement>("#brand");
if (image) image.src = logoUrl;

CSS url(...) references and assets used by HTML templates are also processed. Small assets may be inlined as data URLs based on configuration; larger assets are typically emitted as files. Fingerprinted filenames let browsers cache immutable content while a changed asset receives a new URL.

Use the public directory for files that must keep an exact name or be referenced directly without an import, such as robots.txt or a manifest. Reference these with a root-relative URL in the site's deployment base, or use Vite's BASE_URL when supporting a non-root deployment. Do not put every image in public: imported assets provide build-time checking and hashing.

In CSS and code, prefer imports and module-relative paths when an asset belongs to a component. This makes dependencies discoverable to the build. Check the configured base path when deploying below a domain subpath; hard-coded /assets/... URLs may incorrectly point to the domain root.

Environment Variables and Secrets

Vite can load .env files for different modes, such as development and production. It also exposes built-in values including import.meta.env.MODE, import.meta.env.DEV, and import.meta.env.PROD. Environment values are generally strings, so parse and validate numbers, booleans, and URLs explicitly.

By default, only variables with the configured public prefix—VITE_ by default—are exposed to client-side code:

text
VITE_PUBLIC_API_URL=https://api.example.test
ts
const apiUrl = import.meta.env.VITE_PUBLIC_API_URL;

Anything embedded in a browser bundle can be read by users. Never put passwords, private API keys, signing secrets, or database credentials in a VITE_ variable. A prefix is not encryption; it marks a value for client exposure. Keep secrets in a trusted server environment and access them only from server-side code or an API that enforces authorization.

Common env files include .env, .env.local, and mode-specific files such as .env.production. Local secret-bearing files should be excluded from version control; commit a .env.example containing names and safe placeholders, not real credentials. Restart the development server after changing env files because values are loaded during startup/build. Use the deployment platform's environment settings for production values.

Do not use a client-exposed variable as an authorization control. Hiding a button based on a public configuration value does not protect the API. The server must verify identity and permissions for every sensitive operation.

Build Optimization and Deployment

Vite's production build can apply transforms, minification, CSS extraction, and asset fingerprinting. Common optimization strategies include:

  • Use route-level or feature-level dynamic imports for code that is not needed on the first screen.
  • Import only the functions or components used when dependencies support tree-shaking.
  • Avoid shipping development-only diagnostics in production.
  • Compress static responses at the hosting layer when supported.
  • Analyze generated chunks when a warning or slow page load indicates oversized bundles.
  • Set the correct deployment base path and confirm the host serves the generated output.

Do not split every component into its own chunk. Too many tiny requests and runtime overhead can erase the benefit. Group code along actual navigation or feature boundaries and measure the built result. A minified chunk-size warning is a signal to inspect, not a failure by itself; compare transfer size, caching, loading priority, and user experience.

The build command creates files for deployment; preview serves a local approximation of the production output. Preview is valuable for checking asset URLs, route fallback behavior, and environment-dependent code, but it does not duplicate every hosting platform's server runtime or edge configuration. Test the actual deployment workflow for SSR or platform-specific output.

Troubleshooting Workflow

When a build fails, start with the earliest error, not the final cascade of messages. Confirm the expected Node and package-manager versions, then compare the manifest and lockfile. Run the project's install command, inspect the full import path in the error, and check whether the issue is a missing package, unsupported syntax, Sass module path, environment value, or asset URL.

For Sass errors, verify the Sass implementation is installed, check relative paths and file names, confirm that a module is loaded with @use, and look for invalid configuration order. For Vite issues, reproduce with the project's script, inspect plugin order and config, then test the production build. For missing production assets, search the generated HTML/CSS for the URL and compare it with the deployed base path.

Avoid deleting a lockfile as a routine troubleshooting step. That can resolve every transitive dependency again and introduce unrelated changes. Diagnose the dependency or tooling mismatch first, then make the smallest reviewed update.

Accessibility & Best Practices

  • Commit one package-manager lockfile and use its reproducible CI install command.
  • Pin compatible Node versions through the project's documented runtime configuration.
  • Review dependency and lockfile diffs; do not treat an audit result as a complete security verdict.
  • Prefer Sass modules and explicit namespaces over deprecated @import.
  • Keep Sass tokens and helpers separate from global CSS that emits selectors.
  • Keep secrets out of client-exposed environment variables and browser bundles.
  • Import source assets when build-time hashing and path validation are useful.
  • Test both development behavior and the production build.
  • Measure bundle changes and split code around real usage boundaries.

Practice Exercises

Exercise 1 — Make a project install reproducible

A teammate adds a tool to package.json but forgets to commit the lockfile update. CI runs npm ci and fails. The manifest says "vite": "^8.1.0". Explain what caused the failure, which files should be updated and committed, how ^8.1.0 differs from ~8.1.0, and how you would verify that the project still builds without replacing the entire lockfile unnecessarily.

Exercise 2 — Build a Sass theme and a safe Vite environment

Create a Sass module that defines a configurable brand color and focus-ring mixin, then use it in a button stylesheet with @use. Explain how a barrel module could expose the mixin with @forward. A frontend needs a public API base URL while its server needs a private token: show where each value belongs and how you would ensure the token is not included in the client bundle.

Solutions

Solution 1

npm ci requires the manifest and lockfile to describe the same dependency graph. Adding a package or changing its range without updating the lockfile creates a mismatch, so CI correctly stops rather than guessing a resolution. Use the chosen package manager to update the dependency and lockfile together, inspect the focused diff, and commit both files:

sh
npm install --save-dev vite@^8.1.0
npm ci
npm run build

If Vite is already correctly declared, update the lockfile with npm install instead of adding a duplicate declaration. ^8.1.0 permits compatible updates below 9.0.0, whereas ~8.1.0 permits patch releases below 8.2.0. The exact selected version is recorded in the lockfile. Review that file to ensure the change is limited to expected resolutions, then run tests and the production build. Do not delete the lockfile simply to make CI pass.

Solution 2

scss
// styles/_brand.scss
$color: #174a8b !default;

@mixin focus-ring {
  outline: 2px solid $color;
  outline-offset: 2px;
}
scss
// styles/_index.scss
@forward "brand" show focus-ring, $color;
scss
// styles/components/_button.scss
@use "../index" as brand;

.button:focus-visible {
  @include brand.focus-ring;
}

The barrel forwards an intentional API; consumers use its namespace instead of depending on internal file paths. To customize $color, configure the module on its first load with @use ... with (...) and a !default variable. A CSS custom property is a better choice if the color must change at runtime.

Put the public URL in a client-exposed variable such as VITE_PUBLIC_API_URL and read it through import.meta.env. Keep the private token in a server-only environment variable configured in the deployment platform. Do not give it the VITE_ prefix, import it into browser code, or serialize it into HTML responses. Verify the built client assets do not contain the token, and enforce token use and authorization on the server.