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.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 buildA 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.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 previewThe
&& 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.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.jsThe 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 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.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.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.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.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/apiThe
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.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.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.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.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.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.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.