We use cookies to enhance your experience on the site
CodeWorlds

Building for Production

For three lessons you have been checking the station systems: components, composables, the store, and finally whole operator paths in end-to-end tests. Every indicator is green - green here in the NOVA LAB, on your machine, with the development server running. The telemetry panel, however, is due to fly out to the lander: to the browser of a technician who opens it from the far side of the station, over a link whose latency is measured in minutes. The development server is not flying out there with you.

The

npm run dev
command you have been using since the very first location hands the browser your source files almost untouched. It transforms each
.vue
file only at the moment the browser asks for it, and leaves the rest of the work to the browser native module system. That is why startup is instant, but the price is hundreds of separate requests, no minification, and a
node_modules
directory waiting in the background. For the flight you need exactly the opposite: one counted, packed payload that the browser at the other end of the link downloads in a handful of files.

One command that packs the payload

Packing is handled by a separate Vite mode, launched through the

build
script. Vite then switches from serving files to building them: it reads
index.html
as the entry point, walks every import, compiles each component, and hands the whole thing to Rollup, which glues the modules into bundles and minifies the result. It is the same command in every Vue project standing on Vite, whether you are building a telemetry panel or a game for the crew.

1npm run build

A dozen or so seconds later a

dist
folder appears in the project directory. Notice what did not change along the way: not one of your source files. The build only reads the
src
directory and only writes into
dist
- which is why you can run it as often as you like, and why
dist
belongs in
.gitignore
rather than in the repository.

Memorise that script name exactly, @name, because this is where mistakes are easiest.

npm run
knows no magic commands - it runs only the scripts written into the
scripts
section of
package.json
, and the Vue template built on Vite assumes exactly three of them:
dev
,
build
and
preview
. You will not find
npm run prod
or
npm run compile
in any standard template, and both end with a message about a missing script.
npm run deploy
does get added by hand now and then, but it is a shipping script rather than a building one - and if you ever see it in a project, it almost certainly calls
npm run build
inside itself, because you have to have something to ship first.

Previewing the payload before launch

The built application is often the first place where faults invisible in the laboratory come out: a wrong base path, an image glued in dynamically, a library that only worked because the development server was quietly covering for it. That is what the third Vite script,

preview
, is for. It brings up a plain static file server and serves the contents of
dist
from it - exactly the files you are about to send. It prints the address in the console, and that address differs from the development server one, so both can run at the same time. It is worth running the two commands one after another, chained with a double ampersand.

1npm run build && npm run preview

The

&&
sign is an ordinary shell operator: the second command starts only if the first one finishes successfully. If the build breaks on an import error, the preview will not come up at all and you will not accidentally inspect the previous, stale contents of
dist
. And one caveat, so that there is no misunderstanding:
preview
is an inspection tool, not a production server. There is no compression, no cache headers and no certificate - it exists so you can look at the payload, not so you can hand it to the crew.

What really sits in the dist folder

Since

dist
is everything that flies out to the lander, it is worth knowing what exactly lands in it. The structure is the same every time: a single
index.html
at the root and an
assets
directory holding all the rest. Below is a typical listing after building a panel with a few views.

1dist/
2  index.html
3  assets/
4    index-4f3a9b1c.js
5    index-9c2e7d84.css
6    vendor-1b7e0a52.js
7    TelemetryView-6d41f0ab.js

The string of characters in each file name is a hash computed from its contents. Change one line in the code and the hash changes, so the file name changes with it, and the technician browser has no way to serve the old version from its cache. You do not have to correct anything by hand: the

index.html
inside
dist
already has its references rewritten to the new names. That is why the build generates its own
index.html
instead of copying yours unchanged.

In

dist
you will find only minified JS, CSS and HTML files ready for deployment. There is not a single
.vue
file there - the components were compiled into render functions and melted into the bundles. There is no
node_modules
: the fragments of libraries you actually use sit inside the bundles, and the rest was thrown away. There is no
vite.config.js
either, nor any of the other laboratory configuration files - those are input to the build process, not its output.

There is one folder that is easy to mistake for the result:

public
. It is input as well - you drop into it the files that should reach
dist
with no processing at all,
robots.txt
or the station icon for instance. The names
build
and
out
also circulate through documentation, but they belong to other tools:
build
is the convention in projects from Create React App and webpack,
out
is the static export directory in Next.js. In Vite the default output directory is
dist
- and that is where to look for the result after every build.

The build section of vite.config.js

The defaults are sensible, but sooner or later the mission will force changes: the hosting expects a different directory name, someone asks for source maps, someone else for stronger minification. You describe all of it in

vite.config.js
, inside the
build
object. The four options you start with read as follows:
outDir
is the name of the output directory,
assetsDir
is the subdirectory for bundles and styles inside it,
sourcemap
decides whether maps appear next to the bundles so you can debug minified code in terms of your own source files, and
minify
names the tool that shortens that code.

1// vite.config.js
2import { defineConfig } from 'vite'
3import vue from '@vitejs/plugin-vue'
4
5export default defineConfig({
6  plugins: [vue()],
7
8  build: {
9    outDir: 'dist',
10    assetsDir: 'assets',
11    sourcemap: false,
12    minify: 'esbuild'
13  }
14})

This file changes nothing - and that is the most interesting thing about it. You wrote down exactly the values Vite assumes on its own, so the build will behave identically to a moment ago. Treat it as a checklist: you now know where to reach when the hosting demands a differently named directory. The

defineConfig
function configures nothing at all, it only hands your editor the types, thanks to which a typo in an option name lights up immediately. I recommend keeping source maps switched off in an application exposed publicly, because along with them you publish the readable source of the panel; if you need them for an error reporting system, set
sourcemap: 'hidden'
- the maps will be produced, but the bundle will not point at them.

Leave three other options from this section alone for now, though they are worth knowing by name.

assetsInlineLimit
sets the boundary in bytes below which a small file, a tiny SVG icon for instance, gets pasted straight into the code as a data URI instead of landing separately - one request fewer.
cssCodeSplit
turns on the splitting of styles into parts matching the bundles.
chunkSizeWarningLimit
is the threshold above which Vite prints a warning about an oversized bundle; it counts the size after minification but before the server compression, so the real transfer will be smaller than the number you see in the console.

Silencing the console with the terser minifier

There are certainly a few

console.log
calls left in the panel code - priceless in the laboratory, pointless on the lander. Cutting such calls out is the job of the minifier, but not every minifier can do it. The default Vite minifier is
esbuild
: it is very fast and it is responsible for most of the shortening. The alternative is
terser
, slower but equipped with a meticulous set of switches - among them
drop_console
and
drop_debugger
, which remove console calls and
debugger
statements from the bundle respectively. Terser settings go into a
terserOptions
object, in its
compress
section.

1// vite.config.js
2import { defineConfig } from 'vite'
3import vue from '@vitejs/plugin-vue'
4
5export default defineConfig({
6  plugins: [vue()],
7
8  build: {
9    minify: 'terser',
10    terserOptions: {
11      compress: {
12        drop_console: true,
13        drop_debugger: true
14      }
15    }
16  }
17})

There is a trap here that configurations circulating around the network usually keep quiet about: Vite does not install terser together with itself. If you write

minify: 'terser'
without adding the
terser
package to your development dependencies, the build stops with a message that the minifier is unavailable -
npm install -D terser
is then all it takes. Notice as well what this configuration does not do: it touches neither your source files nor development mode.
console.log
stays in the code and still prints to the console under
npm run dev
, and it disappears only from the contents of
dist
.

My recommendation, @name: stay with the default

esbuild
for as long as you do not specifically need the log stripping - the build is noticeably faster then, and the difference in bundle size turns out to be small. Reach for
terser
deliberately, for that one capability. There is also an
esbuild.drop
option, but it works at the level of the whole transformation rather than the build step alone, so it is easier to silence the laboratory with it by accident.

Splitting the payload into bundles

By default Vite glues your code into a single bundle, and that has an unpleasant side effect on updates. You correct one label in the panel, the file name changes because of the new hash, and the technician downloads the whole thing again - together with Vue, the router and Pinia, which have not moved in months. The cure is to separate the code into bundles that change at different rates. The option for that is

manualChunks
, which you pass inside
rollupOptions.output
, that is, in the settings handed straight to Rollup. In its simplest form it is an object: the key becomes the name of the future bundle, and the value is a list of modules that should end up in it.

1// vite.config.js
2import { defineConfig } from 'vite'
3import vue from '@vitejs/plugin-vue'
4
5export default defineConfig({
6  plugins: [vue()],
7
8  build: {
9    rollupOptions: {
10      output: {
11        manualChunks: {
12          vendor: ['vue', 'vue-router', 'pinia']
13        }
14      }
15    }
16  }
17})

After such a build you will see a separate file in the

assets
directory whose name begins with the word
vendor
. The libraries landed in it together, and until you change their versions its name stays the same - the browser downloads it once and keeps it in cache across further releases of the panel. What did not change along the way: you did not touch a single import in your code. You still write
import { ref } from 'vue'
exactly where you wrote it before, and the decision about splitting the files is made by the build alone.

One rule instead of a roll call

A manual list works fine for three libraries. Once

package.json
starts to swell, it is more convenient to state a rule than to name the modules one by one.
manualChunks
accepts a function as well: Rollup calls it for every module, passing that module path as the
id
argument, and treats the returned string as a bundle name. When the function returns nothing, the module goes wherever it would have gone by default.

1// vite.config.js
2import { defineConfig } from 'vite'
3import vue from '@vitejs/plugin-vue'
4
5export default defineConfig({
6  plugins: [vue()],
7
8  build: {
9    rollupOptions: {
10      output: {
11        manualChunks(id) {
12          if (id.includes('node_modules')) {
13            return 'vendor'
14          }
15        }
16      }
17    }
18  }
19})

This version says it briefly: whatever comes from

node_modules
lands in the
vendor
bundle, and everything else stays as it was. By adding another condition you can split out a single heavy library - the telemetry charting engine, say, needed on one screen only. Since you have a choice here, let me say plainly what I recommend: start with the object form, because it is readable and you see exactly what goes where. Reach for the function only once the list starts drifting away from reality - an over-aggressive rule can shatter the bundles so badly that the browser downloads more files on startup than it needs.

Mission parameters, or environment variables

The telemetry panel asks the mission server for its data. In the laboratory that server stands on your machine; on the lander it lives at a completely different address. Writing the address into the code by hand means that before every build somebody has to remember to swap it, and sooner or later they will forget, and the production panel will start knocking at localhost. Vite solves this with

.env
files, read at the moment of building. The
.env
file always applies,
.env.production
is added to it during a production build, and
.env.development
during the development server, with values from the more specific file winning.

1# .env
2VITE_API_URL=http://localhost:3000
3
4# .env.production
5VITE_API_URL=https://telemetry.nova-lab.mars/api

The

VITE_
prefix is not decoration, it is a safety gate. Into the code that reaches the browser Vite lets through only variables carrying that prefix, and all the rest stay on the side of the build process. The conclusion is sharper than it looks at first glance: since a prefixed variable lands in the bundle, anybody who opens
dist
can read its value. Addresses and feature flags - yes; keys and passwords - never.

A classic mistake hides here too. The

VUE_APP_
prefix together with reading through
process.env
is the convention of Vue CLI, the webpack based predecessor. In a project on Vite the
process
object simply does not exist in the browser, and such a read ends with an error. Nor will a home made
config.js
file with arbitrarily named variables help - that is an ordinary module which rides into the bundle just like the rest of the code, only without any mechanism for switching values between environments. And just in case: the claim that Vite does not support environment variables at all is untrue. It supports them, on its own terms.

Reading a variable in code

On the code side you read the variables from the

import.meta.env
object.
import.meta
itself is a standard piece of JavaScript - an object with metadata about the current module - and Vite adds an
env
field to it. The full expression therefore consists of three parts in exactly this order:
import.meta
, then
.env
, then the variable name together with its prefix, which gives
import.meta.env.VITE_API_URL
.

1// api/telemetry.js
2const apiUrl = import.meta.env.VITE_API_URL
3
4export async function fetchModuleTelemetry(moduleId) {
5  const response = await fetch(`${apiUrl}/modules/${moduleId}`)
6
7  if (!response.ok) {
8    throw new Error('Telemetry request failed')
9  }
10
11  return response.json()
12}

It matters that you understand what really happens here. This is not a lookup in some dictionary while the application is running - Vite replaces the whole

import.meta.env.VITE_API_URL
expression with a text literal back at build time. In the file inside
dist
there is no trace of
import.meta
, there is a finished address. That has one practical consequence: the variable name has to be written out literally. A key assembled on the fly, out of a prefix and a name held in a variable, will not be substituted and will give you
undefined
as the result.

The mode the application runs in

Alongside your own variables Vite puts several of its own into

import.meta.env
. The first of them is
MODE
, the name of the mode written as text: under a plain
npm run dev
it will be
development
, and after
npm run build
it will be
production
. The next two,
DEV
and
PROD
, carry the same information as boolean values, which makes them convenient to use in conditions.

1if (import.meta.env.DEV) {
2  console.log('Mission control mode:', import.meta.env.MODE)
3}

That condition costs not a single byte in the production version. Since

import.meta.env.DEV
is replaced by a literal, the production bundle ends up with an always false condition, and the minifier throws the whole dead branch out together with its contents. It is a very convenient pattern for diagnostic panels meant for the laboratory crew: you write them normally, next to the rest of the code, and the technician on the lander does not even download them.

Modes do not end with those two names. The command

npm run build -- --mode staging
builds the application in a mode called
staging
and loads the
.env.staging
file - the standard way to describe a test environment standing somewhere between the laboratory and the lander. The double hyphen before
--mode
is required, because it is what tells the package manager to pass the argument further on, to Vite.
MODE
will then equal
staging
, while
PROD
stays true by default, because that flag is decided by the kind of command, not by the name of the mode.

What Vite does without being asked

Before you start tightening anything, it is worth knowing how much work the build does for you and - more importantly - which technique is responsible for what, because these four names get confused with one another.

Tree shaking is the cutting out of unused code. Rollup reads the imports and exports of ES modules, builds a dependency graph out of them, and removes from the bundle every export that nobody refers to. Import one function from a large library and only that one function, together with whatever it needs itself, reaches

dist
. That is why static imports at the top of the file are the standard today: only they give the tool certainty about what is genuinely used.

Minification is the shortening of the code that remains. Spaces, indentation and comments disappear, and the names of local variables shrink to single letters. Notice the difference: the minifier does not wonder whether a given module is needed by anybody, it merely squeezes it. Stripping the comments alone is a small fragment of that work, and certainly not the mechanism that removes unused code.

Code splitting is the division of the result into several files - exactly what you were doing a moment ago with

manualChunks
. Nothing is lost here, the code is only moved into a separate bundle.

Lazy loading, by contrast, is a strategy at the time the application runs: a bundle produced by splitting is downloaded only once the user actually needs it. It is a consequence of code splitting, not a separate code cleaning technique.

Two more misunderstandings are worth dealing with. The build does not pack anything into a ZIP archive - a browser would not know how to run such a file. Compression of course exists, it is called gzip or brotli, it happens at the level of HTTP transport, the server is responsible for it, and it has nothing to do with removing code. It is equally untrue that Vite optimizes nothing and an external tool is needed for that:

vite build
is Rollup with the full set of optimizations switched on by default.

Lazy routes in practice

Code splitting does not have to be manual. The most effective split comes out of a dynamic import, that is, an

import()
call inside a function instead of a static
import
at the top of the file. Rollup treats such a place as a boundary: everything beyond it travels into a separate bundle. In the router this looks like handing over a function that will fetch the component later, instead of the ready component itself.

1// router/index.js
2import { createRouter, createWebHistory } from 'vue-router'
3import MissionControl from '../views/MissionControl.vue'
4
5const routes = [
6  { path: '/', component: MissionControl },
7  { path: '/telemetry', component: () => import('../views/TelemetryView.vue') },
8  { path: '/crew', component: () => import('../views/CrewView.vue') }
9]
10
11export default createRouter({
12  history: createWebHistory(),
13  routes
14})

The starting screen goes into the main bundle, because the technician will always see it. The other two views get their own files, downloaded only on the first entry to the given route - and that is precisely the kind of name you saw in the sample listing of

dist
. What did not change: the components themselves. The
TelemetryView.vue
file looks exactly as it did before, and all that changed is the way the router points at it.

Measure before you start optimizing

Optimizing by feel usually ends with tightening things that weigh next to nothing. So before you move anything away from the defaults, measure. The tool for measuring is the

rollup-plugin-visualizer
plugin, added to the development dependencies with
npm install -D rollup-plugin-visualizer
and then written into the
plugins
list. The
open
option opens the report in the browser as soon as the build finishes, while
gzipSize
and
brotliSize
add the sizes after compression to it.

1// vite.config.js
2import { defineConfig } from 'vite'
3import vue from '@vitejs/plugin-vue'
4import { visualizer } from 'rollup-plugin-visualizer'
5
6export default defineConfig({
7  plugins: [
8    vue(),
9    visualizer({
10      open: true,
11      gzipSize: true,
12      brotliSize: true
13    })
14  ]
15})

The report is a map of rectangles: the bigger the field, the heavier the module. One glance is usually enough to see that half the bundle is eaten by a single library pulled in for a single function. Look above all at the compressed sizes - those are the ones that correspond to what will really cross the link to the lander, because the raw size can be several times larger and frightens you for nothing. The plugin itself changes nothing in the bundle, it only measures, which is why you keep it at the end of the

plugins
list.

Images weigh more than code

One last stop, because in a typical panel it is not the code that weighs the most. A single unprocessed photograph of the Martian surface can outweigh the whole application together with Vue. Vite does not compress images on its own - it moves them into

dist
and adds a hash to the name, but does not touch the contents. Compression is the job of the
vite-plugin-image-optimizer
plugin, installed with
npm install -D vite-plugin-image-optimizer
. It also needs the
sharp
and
svgo
packages, which it uses underneath. For each format you give a
quality
, that is, the quality on a scale up to a hundred.

1// vite.config.js
2import { defineConfig } from 'vite'
3import vue from '@vitejs/plugin-vue'
4import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
5
6export default defineConfig({
7  plugins: [
8    vue(),
9    ViteImageOptimizer({
10      jpg: { quality: 80 },
11      png: { quality: 80 },
12      webp: { quality: 80 }
13    })
14  ]
15})

A value of 80 means lossy compression at which the difference is usually invisible to the naked eye, while the file sheds tens of percent. The most important part, though, is what you do not have to do: you change not a single

<img>
in your templates and you swap no files in the
src
directory. The plugin works on the copy travelling to
dist
, so the originals in the repository stay untouched and at any moment you can change the settings and build everything again from scratch.

A payload that has been built, inspected and measured is everything you need before launch - what remains is the question of which platform to send it to, and that is what the next lesson takes on. Until then, build yourself a reflex: first

npm run build
, then
npm run preview
, then a look at the size report, and only after all that anything further.

Remember, @name:

npm run build
is not another way of running the application - it is the packing of the whole laboratory into one payload that will fly without you.

Go to CodeWorlds