4 min read
On this page

Project Setup

The standard way to create a Vue 3 project is create-vue, which scaffolds a Vite-powered application with optional TypeScript, Vue Router, Pinia, and testing. Unlike Create React App or Angular CLI, the Vue toolchain is fast, minimal, and configurable without ejecting.

Creating a Project with create-vue

npm create vue@latest

Project name: my-app
Add TypeScript? Yes
Add JSX Support? No
Add Vue Router? Yes
Add Pinia? Yes
Add Vitest? Yes
Add an End-to-End Testing Solution? Playwright
Add ESLint? Yes
Add Prettier? Yes
Add Vue DevTools extension? Yes
cd my-app
npm install
npm run dev

The dev server starts in under a second. Hot Module Replacement (HMR) updates the browser instantly when you save a file. No full-page reload, no lost component state.

Project Structure

After scaffolding, the project looks like this:

my-app/
  src/
    assets/          # Static assets (images, fonts, global CSS)
    components/      # Reusable UI components
    composables/     # Composition API functions (useXxx)
    router/          # Vue Router configuration
      index.ts
    stores/          # Pinia stores
    views/           # Page-level components (mapped to routes)
    App.vue          # Root component
    main.ts          # Application entry point
  public/            # Static files served as-is
  index.html         # HTML entry point
  vite.config.ts     # Vite configuration
  tsconfig.json      # TypeScript configuration
  package.json

Each directory has a clear purpose:

  • components/: Reusable pieces of UI. A BaseButton.vue, a UserAvatar.vue, a DataTable.vue. These are not tied to specific routes.
  • views/: Components that represent full pages. HomeView.vue, UserProfileView.vue. These map to routes in the router configuration.
  • composables/: Reusable logic functions. useAuth.ts, useFetch.ts, useLocalStorage.ts. Named with the use prefix by convention.
  • stores/: Pinia state stores. useUserStore.ts, useCartStore.ts. Each store manages a slice of global state.
  • router/: Route definitions and navigation guards.

The Entry Point

main.ts bootstraps the application:

// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

import './assets/main.css'

const app = createApp(App)

app.use(createPinia())
app.use(router)

app.mount('#app')

createApp creates the Vue application instance. app.use() installs plugins. app.mount() attaches the app to the DOM element with id="app" in index.html. The order matters: plugins must be installed before mounting.

Vue Router Configuration

The router maps URL paths to view components:

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView,
    },
    {
      path: '/about',
      name: 'about',
      // Lazy loading: only loads when the route is visited
      component: () => import('@/views/AboutView.vue'),
    },
    {
      path: '/users/:id',
      name: 'user-profile',
      component: () => import('@/views/UserProfileView.vue'),
      props: true,
    },
  ],
})

export default router

createWebHistory uses the browser's History API for clean URLs without hash fragments. Lazy-loaded routes using dynamic import() split the bundle so users only download the code they need.

Pinia Store Setup

A basic Pinia store using setup syntax:

// src/stores/useUserStore.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', () => {
  const user = ref<{ id: number; name: string; email: string } | null>(null)
  const token = ref<string | null>(null)

  const isAuthenticated = computed(() => !!token.value)
  const displayName = computed(() => user.value?.name ?? 'Guest')

  async function login(email: string, password: string) {
    const res = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    })
    const data = await res.json()
    user.value = data.user
    token.value = data.token
  }

  function logout() {
    user.value = null
    token.value = null
  }

  return { user, token, isAuthenticated, displayName, login, logout }
})

TypeScript Configuration

The default tsconfig.json works for most projects. Key settings:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "preserve",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

The @/* path alias lets you write import Foo from '@/components/Foo.vue' instead of relative paths with ../../. This alias is configured in both tsconfig.json (for TypeScript) and vite.config.ts (for the bundler).

Vite Configuration

// vite.config.ts
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
  server: {
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
})

The server.proxy configuration forwards API requests during development. Requests to /api/* go to your backend at port 8080 without CORS issues.

Auto-Imports with unplugin-auto-import

Tired of writing import { ref, computed, watch } from 'vue' in every file? unplugin-auto-import handles it:

npm install -D unplugin-auto-import
// vite.config.ts
import AutoImport from 'unplugin-auto-import/vite'

export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      imports: ['vue', 'vue-router', 'pinia'],
      dts: 'src/auto-imports.d.ts',
    }),
  ],
})

Now ref, computed, watch, useRouter, useRoute, and other common imports are available without explicit import statements:

<script setup lang="ts">
// No import needed: ref and computed are auto-imported
const count = ref(0)
const doubled = computed(() => count.value * 2)
</script>

The plugin generates a src/auto-imports.d.ts file so TypeScript knows about these globals. You should commit this file to your repository.

The Dev Server & HMR

Vite's dev server uses native ES modules in the browser. Instead of bundling your entire application on startup, it serves each module individually. The result is sub-second startup regardless of project size.

Hot Module Replacement (HMR) is Vue-aware. When you edit a component:

  • Template changes update instantly without losing component state.
  • Script changes re-execute the setup function, resetting local state but preserving Pinia store state.
  • Style changes apply immediately with no flash.
<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
  <!-- Edit this text and save: the browser updates instantly -->
  <!-- count retains its value if you only changed the template -->
  <button @click="count++">Count: {{ count }}</button>
</template>

<style scoped>
/* Edit styles here: they apply immediately, no reload */
button {
  padding: 0.5rem 1rem;
  background: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

Common Pitfalls

  • Skipping TypeScript: Vue 3's TypeScript support is excellent. Starting without it and adding it later is painful. Always select TypeScript during project creation.
  • Forgetting the path alias in both configs: The @ alias needs to be configured in both tsconfig.json (for type checking) and vite.config.ts (for module resolution). Missing either causes confusing errors.
  • Not using lazy loading for routes: Every route component imported statically adds to the initial bundle. Use dynamic import() for all routes except the home page.
  • Ignoring the composables directory: New developers often put reusable logic inside components instead of extracting it to composables. Create the composables/ directory from day one and use it.
  • Over-configuring Vite: Vite's defaults are sensible. Most projects need only the Vue plugin, the path alias, and possibly a dev proxy. Adding plugins and configuration you do not yet need creates maintenance burden.

Key Takeaways

  • create-vue scaffolds a Vite-powered project with TypeScript, Vue Router, Pinia, and testing in under a minute.
  • The project structure separates components, views, composables, stores, and router configuration into distinct directories.
  • main.ts bootstraps the app by creating the Vue instance, installing plugins, and mounting to the DOM.
  • Vite provides sub-second startup and Vue-aware HMR that preserves component state during development.
  • unplugin-auto-import eliminates repetitive imports for Vue, Vue Router, and Pinia APIs.
  • Always start with TypeScript, lazy-loaded routes, and the @ path alias configured in both TypeScript and Vite configs.