Vue.js course Β· Module 12: Testing & Deploy

Building for Production

21 min read
In this lesson14

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 Rolldown, the Rust-based bundler Vite has used since version 8. Rolldown glues the modules into bundles, and the Oxc minifier shortens 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 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: 'oxc'
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 - you only have to ask it explicitly. The default minifier in Vite 8 is Oxc (minify: 'oxc'): it is very fast and it is responsible for most of the shortening, while the old 'esbuild' value is deprecated and will be removed in future versions. 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: stay with the default Oxc - the build is noticeably faster, and you can strip the logs without terser by setting dropConsole: true in build.rolldownOptions.output.minify.compress. Reach for terser deliberately, when you need one of its meticulous switches. The esbuild.drop option you will find in older tutorials is deprecated in Vite 8, just like the whole esbuild option.

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 codeSplitting, which you pass inside build.rolldownOptions.output, that is, in the settings handed straight to Rolldown. It takes a list of groups: each group has a name, which becomes the name of the future bundle, and a test - a regular expression matched against the module path, in which [\\/] catches both the slash and the backslash of Windows paths. Older tutorials show manualChunks inside rollupOptions here: Vite 8 no longer supports its object form and aborts the build with an error, and it has deprecated the function form.

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    rolldownOptions: {
10      output: {
11        codeSplitting: {
12          groups: [
13            { name: 'vendor', test: /node_modules[\\/](vue|@vue|vue-router|pinia)[\\/]/ }
14          ]
15        }
16      }
17    }
18  }
19})

The group also catches the @vue packages, because the internals of Vue itself are made of them. 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. A group's name field accepts a function as well: Rolldown calls it for every module, passing the module path as the moduleId argument, and treats the returned string as a bundle name. When the function returns null, the group skips the module, and it 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    rolldownOptions: {
10      output: {
11        codeSplitting: {
12          groups: [
13            {
14              name(moduleId) {
15                if (moduleId.includes('node_modules')) {
16                  return 'vendor'
17                }
18                return null
19              }
20            }
21          ]
22        }
23      }
24    }
25  }
26})

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 a group using a regular expression, 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. Rolldown 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 codeSplitting. 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 Rolldown 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. Rolldown 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: 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.

Code for this lesson: App.vue
1<script setup>
2import { ref, computed } from 'vue'
3
4// NOVA LAB - Deployment Platforms Comparison
5const platforms = ref([
6  {
7    id: 1,
8    name: 'Vercel',
9    logo: 'β–²',
10    features: ['Zero Config', 'Edge Network', 'Serverless Functions', 'Auto SSL'],
11    buildTime: 45,
12    pricing: 'Free tier available',
13    bestFor: 'Next.js, React apps',
14    color: '#000'
15  },
16  {
17    id: 2,
18    name: 'Netlify',
19    logo: 'β—†',
20    features: ['Continuous Deployment', 'Form Handling', 'Split Testing', 'Edge Functions'],
21    buildTime: 60,
22    pricing: 'Free tier available',
23    bestFor: 'Static sites, JAMstack',
24    color: '#00C7B7'
25  },
26  {
27    id: 3,
28    name: 'Render',
29    logo: '●',
30    features: ['Full Stack Apps', 'Databases', 'Cron Jobs', 'Background Workers'],
31    buildTime: 90,
32    pricing: 'Free tier available',
33    bestFor: 'Full-stack apps',
34    color: '#46E3B7'
35  },
36  {
37    id: 4,
38    name: 'Railway',
39    logo: '',
40    features: ['One-click Deploy', 'Databases', 'Volumes', 'Private Networking'],
41    buildTime: 75,
42    pricing: 'Usage-based',
43    bestFor: 'Backend services',
44    color: '#A020F0'
45  }
46])
47
48const selectedPlatform = ref(null)
49const deployStatus = ref('idle') // idle, deploying, deployed, error
50
51const deployProgress = ref(0)
52let deployInterval = null
53
54function selectPlatform(platform) {
55  selectedPlatform.value = platform
56  deployStatus.value = 'idle'
57  deployProgress.value = 0
58}
59
60function startDeployment() {
61  if (!selectedPlatform.value) return
62
63  deployStatus.value = 'deploying'
64  deployProgress.value = 0
65
66  const buildTime = selectedPlatform.value.buildTime
67  const increment = 100 / (buildTime / 10)
68
69  deployInterval = setInterval(() => {
70    deployProgress.value += increment
71
72    if (deployProgress.value >= 100) {
73      clearInterval(deployInterval)
74      deployProgress.value = 100
75      deployStatus.value = 'deployed'
76    }
77  }, 100)
78}
79
80function resetDeployment() {
81  if (deployInterval) {
82    clearInterval(deployInterval)
83  }
84  deployStatus.value = 'idle'
85  deployProgress.value = 0
86  selectedPlatform.value = null
87}
88
89const deploymentUrl = computed(() => {
90  if (!selectedPlatform.value || deployStatus.value !== 'deployed') return ''
91  const platformName = selectedPlatform.value.name.toLowerCase()
92  return `https://nova-lab-mars.${platformName}.app`
93})
94</script>
95
96<template>
97  <div class="deployment-center">
98    <div class="header">
99      <h1>Deployment Platform Selector</h1>
100      <p>NOVA LAB - Choose Your Launch Platform</p>
101    </div>
102
103    <div class="platforms-grid">
104      <div
105        v-for="platform in platforms"
106        :key="platform.id"
107        class="platform-card"
108        :class="{ selected: selectedPlatform?.id === platform.id }"
109        @click="selectPlatform(platform)"
110      >
111        <div class="platform-logo">{{ platform.logo }}</div>
112        <h3>{{ platform.name }}</h3>
113        <div class="platform-info">
114          <div class="build-time">
115            ⏱~{{ platform.buildTime }}s build
116          </div>
117          <div class="pricing">{{ platform.pricing }}</div>
118          <div class="best-for">{{ platform.bestFor }}</div>
119        </div>
120        <div class="features">
121          <div v-for="feature in platform.features" :key="feature" class="feature">
122            {{ feature }}
123          </div>
124        </div>
125      </div>
126    </div>
127
128    <div v-if="selectedPlatform" class="deploy-panel">
129      <h2>Deploy to {{ selectedPlatform.name }}</h2>
130
131      <div class="deploy-status" :class="deployStatus">
132        <template v-if="deployStatus === 'idle'">
133          Ready to deploy Mars Mission App
134        </template>
135        <template v-else-if="deployStatus === 'deploying'">
136          Deploying... {{ Math.round(deployProgress) }}%
137        </template>
138        <template v-else-if="deployStatus === 'deployed'">
139          Successfully deployed!
140        </template>
141      </div>
142
143      <div v-if="deployStatus === 'deploying'" class="progress-bar">
144        <div class="progress-fill" :style="{ width: deployProgress + '%' }"></div>
145      </div>
146
147      <div v-if="deployStatus === 'deployed'" class="deployment-info">
148        <p class="url"><a :href="deploymentUrl" target="_blank">{{ deploymentUrl }}</a></p>
149        <p class="success-msg">Your Mars Mission is now live on {{ selectedPlatform.name }}!</p>
150      </div>
151
152      <div class="actions">
153        <button
154          v-if="deployStatus === 'idle'"
155          @click="startDeployment"
156          class="deploy-btn"
157        >
158          Deploy Now
159        </button>
160        <button
161          v-if="deployStatus === 'deployed'"
162          @click="resetDeployment"
163          class="reset-btn"
164        >
165          New Deployment
166        </button>
167      </div>
168    </div>
169  </div>
170</template>
171
172<style scoped>
173.deployment-center {
174  background: #0a0e27;
175  color: #00ff88;
176  padding: 2rem;
177  min-height: 100vh;
178  font-family: 'Courier New', monospace;
179}
180
181.header {
182  text-align: center;
183  margin-bottom: 2rem;
184  border-bottom: 2px solid #00b4d8;
185  padding-bottom: 1rem;
186}
187
188.header h1 {
189  color: #00ff88;
190  text-shadow: 0 0 10px #00ff88;
191  margin: 0;
192}
193
194.platforms-grid {
195  display: grid;
196  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
197  gap: 1.5rem;
198  margin-bottom: 2rem;
199}
200
201.platform-card {
202  background: rgba(0, 180, 216, 0.1);
203  border: 2px solid #00b4d8;
204  border-radius: 8px;
205  padding: 1.5rem;
206  cursor: pointer;
207  transition: all 0.3s;
208}
209
210.platform-card:hover {
211  border-color: #00ff88;
212  transform: translateY(-5px);
213  box-shadow: 0 5px 20px rgba(0, 255, 136, 0.2);
214}
215
216.platform-card.selected {
217  border-color: #00ff88;
218  background: rgba(0, 255, 136, 0.1);
219  box-shadow: 0 0 20px rgba(0, 255, 136, 0.3);
220}
221
222.platform-logo {
223  font-size: 3rem;
224  text-align: center;
225  margin-bottom: 1rem;
226}
227
228.platform-card h3 {
229  color: #00ff88;
230  text-align: center;
231  margin: 0 0 1rem 0;
232}
233
234.platform-info {
235  font-size: 0.85rem;
236  color: #00b4d8;
237  margin-bottom: 1rem;
238}
239
240.platform-info > div {
241  margin-bottom: 0.5rem;
242}
243
244.features {
245  display: flex;
246  flex-direction: column;
247  gap: 0.5rem;
248}
249
250.feature {
251  font-size: 0.8rem;
252  color: #fff;
253  padding: 0.25rem 0.5rem;
254  background: rgba(0, 255, 136, 0.05);
255  border-radius: 4px;
256}
257
258.deploy-panel {
259  background: rgba(0, 180, 216, 0.1);
260  border: 2px solid #00b4d8;
261  border-radius: 8px;
262  padding: 2rem;
263}
264
265.deploy-panel h2 {
266  color: #00ff88;
267  margin-top: 0;
268  text-align: center;
269}
270
271.deploy-status {
272  text-align: center;
273  padding: 1rem;
274  border-radius: 8px;
275  margin-bottom: 1rem;
276  font-size: 1.1rem;
277}
278
279.deploy-status.idle {
280  background: rgba(0, 180, 216, 0.2);
281  color: #00b4d8;
282}
283
284.deploy-status.deploying {
285  background: rgba(255, 165, 0, 0.2);
286  color: #ffa500;
287  animation: pulse 2s infinite;
288}
289
290.deploy-status.deployed {
291  background: rgba(0, 255, 136, 0.2);
292  color: #00ff88;
293}
294
295@keyframes pulse {
296  0%, 100% { opacity: 1; }
297  50% { opacity: 0.7; }
298}
299
300.progress-bar {
301  height: 20px;
302  background: rgba(0, 255, 136, 0.1);
303  border-radius: 10px;
304  overflow: hidden;
305  margin-bottom: 1rem;
306}
307
308.progress-fill {
309  height: 100%;
310  background: linear-gradient(90deg, #00b4d8, #00ff88);
311  transition: width 0.3s;
312}
313
314.deployment-info {
315  text-align: center;
316  margin: 1.5rem 0;
317}
318
319.url {
320  color: #00b4d8;
321  font-size: 1.1rem;
322}
323
324.url a {
325  color: #00ff88;
326  text-decoration: none;
327}
328
329.success-msg {
330  color: #fff;
331  margin-top: 0.5rem;
332}
333
334.actions {
335  text-align: center;
336}
337
338.deploy-btn, .reset-btn {
339  padding: 1rem 2rem;
340  font-size: 1rem;
341  font-family: 'Courier New', monospace;
342  border-radius: 8px;
343  cursor: pointer;
344  border: 2px solid;
345  transition: all 0.3s;
346}
347
348.deploy-btn {
349  background: #00ff88;
350  color: #0a0e27;
351  border-color: #00ff88;
352}
353
354.deploy-btn:hover {
355  box-shadow: 0 0 20px #00ff88;
356  transform: scale(1.05);
357}
358
359.reset-btn {
360  background: rgba(0, 180, 216, 0.2);
361  color: #00b4d8;
362  border-color: #00b4d8;
363}
364
365.reset-btn:hover {
366  background: #00b4d8;
367  color: #0a0e27;
368}
369</style>

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Which command builds a Vue + Vite application for production?

  2. 2. In which folder does Vite create the production build by default?

These are 2 of 6 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Complete vite.config.js with build configuration: outDir, minify, sourcemap, and a separate vendor bundle (in Vite 8 through codeSplitting instead of manualChunks).

  • Click in order

    Arrange the commands in order: building and previewing the production build:

  • Horizontal ordering

    Arrange the syntax for reading an environment variable in Vite:

  • Code editor

    Configure .env and .env.production files with VITE_API_URL variables and use them in a component.

Useful articles