Vue – Set Up a Project with Vite

October 12, 20255 min readUpdated 8/24/2026

Vue projects are created with a scaffolder and built by Vite. Vite is by Evan You, the same author as Vue, and it replaced the old Vue CLI and its webpack build. In development it serves your source as native ES modules with no bundling at all, which is why the dev server starts in well under a second and stays fast as the project grows.

Creating a project

npm create vue@latest my-app

It asks a short series of yes/no questions — TypeScript, JSX, Vue Router, Pinia, testing, ESLint. Say yes to Router and Pinia to follow this track closely; everything else can be added later without pain.

cd my-app
npm install
npm run dev

That serves the app on http://localhost:5173 and reloads as you edit.

What it generated

my-app/
  index.html          <- the real entry point, not a template
  vite.config.js      <- build and dev-server config
  package.json
  public/             <- copied to the output as-is, never processed
  src/
    main.js           <- creates the app and mounts it
    App.vue           <- the root component
    assets/           <- imported by your code, so it IS processed
    components/
    router/index.js   <- if you chose Router
    stores/           <- if you chose Pinia

index.html is the entry point

This is the part that surprises people arriving from webpack. In Vite, index.html is not a template that a plugin injects tags into — it is the source file, and the <script type="module"> in it is what pulls in your whole application.

<!doctype html>
<!-- data-bs-theme here rather than set from JS: doing it in script causes a
     visible flash of the light theme before Vue mounts. -->
<html lang="en" data-bs-theme="dark">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
    <title>ReelCMS</title>
    <meta name="description" content="Short-video content management, powered by MongoDB." />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
      rel="stylesheet"
    />
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

Two consequences worth knowing straight away. Anything you want in the document head — fonts, the favicon, meta tags — you write here, in ordinary HTML. And the build rewrites that script tag to point at the hashed bundle, so the file you author is the file that ships, with the URLs corrected.

<div id="app"></div> is the mount point. Everything Vue renders lives inside it.

src/main.js creates the application

import { createApp } from "vue";
import { createPinia } from "pinia";

// The navbar toggle and any other data-bs-* behaviour need Bootstrap's JS. The
// bundle includes Popper, which the dropdown and tooltip plugins depend on.
import "bootstrap/dist/js/bootstrap.bundle.min.js";

import App from "./App.vue";
import router from "./router";
import "./assets/styles.css";

createApp(App).use(createPinia()).use(router).mount("#app");

Read the last line right to left. createApp(App) builds an application instance around the root component. Each .use(...) installs a plugin — Pinia and the router are both plugins, which is why they are available to every component without any component importing them. .mount("#app") renders it into that div.

Nothing is global. Two createApp calls on one page produce two independent applications with their own plugins — which is what makes Vue practical for embedding a widget into a page you do not own.

Note also that the CSS is imported, not linked. Vite treats a stylesheet as a module like anything else, so it is processed, hashed and — in production — extracted into a real CSS file.

vite.config.js

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
  plugins: [vue()],
  server: {
    // 5176: pizza-react has 5173, and stayhub's two frontends take 5174 and
    // 5175. strictPort so a collision fails loudly instead of silently moving
    // the app to another port that nothing else is configured for.
    port: 5176,
    strictPort: true,
  },
  preview: { port: 4176, strictPort: true },
});

plugins: [vue()] is the whole of what makes .vue files work. The rest here is this application's own choice: a fixed port, and strictPort so a collision fails loudly instead of silently moving the app to a port nothing else is configured for.

public/ versus src/assets/

A distinction that causes a lot of confused half-hours:

public/ is copied to the output untouched. Reference it by absolute URL — /favicon.svg. Nothing hashes it and nothing checks it exists.

src/assets/ is imported by your code. Vite hashes it for cache busting, inlines it if it is tiny, and fails the build if it is missing — which is the reason to prefer it.

Talking to a backend in development

Your API almost certainly runs on a different port, and the browser will refuse the request. Two options.

The demo application points at the API's real origin and lets the server send CORS headers:

VITE_USE_MOCK=false
VITE_API_BASE=http://localhost:8087

The alternative is a dev-server proxy, which sidesteps CORS entirely by making the request same-origin:

// vite.config.js -- inside defineConfig({ ... })
server: {
  proxy: {
    // Requests to /api are forwarded to the backend, so the browser only
    // ever sees one origin and CORS never enters into it. Your fetch calls
    // then use a relative "/api/reels" with no base URL at all.
    "/api": { target: "http://localhost:8087", changeOrigin: true },
  },
},

Neither exists in production, where the built files are usually served by the same host as the API or by a CDN in front of it. Lesson 27 covers that.

Environment variables

Vite exposes variables on import.meta.env, and only those prefixed with VITE_:

const BASE = import.meta.env.VITE_API_BASE ?? "http://localhost:8087";

The prefix is a safety rail. Anything exposed this way is compiled into the bundle and readable by anyone who opens the network tab, so the rule is that a secret never gets a VITE_ prefix. Vite also reads .env at startup only — changing it needs a dev-server restart, not a page reload.

The commands you will actually run

npm run dev       # dev server, hot module replacement
npm run build     # production build into dist/
npm run preview   # serve dist/ locally, to check the real build

preview is worth the habit. It serves the actual production output, which is where you find the problems that only exist after the build — a missing environment variable, or routes that 404 on refresh because the host has no history-mode rewrite.

Next: The Single-File Component — what is actually inside a .vue file.