Vue.js powers millions of websites — from small startups to enterprise giants like Alibaba, Xiaomi, and GitLab. It is consistently ranked as the most-loved JavaScript framework in developer surveys thanks to its gentle learning curve and powerful progressive architecture. This roadmap shows exactly what to learn, in what order, with realistic timelines for 2025.
At a glance
| Phase | Topics | Timeline |
|---|---|---|
| 1 | JavaScript & web foundations | Weeks 1–4 |
| 2 | Vue 3 core: templates & reactivity | Weeks 5–8 |
| 3 | Component architecture | Weeks 9–11 |
| 4 | Vue Router & navigation | Weeks 12–13 |
| 5 | Pinia state management | Weeks 14–15 |
| 6 | API integration & async patterns | Weeks 16–17 |
| 7 | TypeScript with Vue 3 | Weeks 18–20 |
| 8 | Testing (Vitest + Vue Test Utils) | Weeks 21–23 |
| 9 | Nuxt.js & SSR | Weeks 24–27 |
| 10 | DevOps & deployment | Weeks 28–29 |
| 11 | Portfolio & job search | Weeks 30–32 |
Phase 1 — JavaScript & web foundations
Vue is a JavaScript framework, so solid JS knowledge is non-negotiable before touching .vue files.
Core JavaScript to master first:
// ES6+ features you'll use daily in Vue
const user = { name: "Ana", age: 28 }
const { name, age } = user // destructuring
const updated = { ...user, age: 29 } // spread (immutability pattern)
// Arrow functions and this context
const double = (n) => n * 2
// Array methods
const active = users.filter(u => u.active)
const names = users.map(u => u.name)
const total = items.reduce((sum, i) => sum + i.price, 0)
// Promises and async/await
async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error("Not found")
return res.json()
}
// Optional chaining and nullish coalescing
const city = user?.address?.city ?? "Unknown"
Topics checklist before Vue:
| Topic | Why it matters |
|---|---|
const/let, arrow functions |
Vue SFC syntax relies on them |
| Destructuring & spread | Used constantly in defineProps, emits |
| Array methods (map/filter/reduce) | Reactive list manipulation |
| Promises / async-await | Every API call in Vue |
| ES modules (import/export) | How Vue components import each other |
this and closures |
Options API uses this heavily |
| DOM basics | Understanding what Vue compiles to |
Resources: MDN Web Docs, javascript.info (free), "You Don't Know JS" (free on GitHub).
Phase 2 — Vue 3 core: templates & reactivity
Start with the Composition API — it is the modern standard for Vue 3.
Single File Components (.vue)
Every Vue file is split into three blocks:
<script setup>
// logic (Composition API)
import { ref, computed } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
function increment() {
count.value++
}
</script>
<template>
<!-- HTML + Vue directives -->
<button @click="increment">
Count: {{ count }} (doubled: {{ doubled }})
</button>
</template>
<style scoped>
/* CSS scoped to this component */
button { padding: 8px 16px; }
</style>
Reactivity: ref vs reactive
ref |
reactive |
|
|---|---|---|
| Use for | Primitives & any value | Objects & arrays |
| Access in script | .value required |
Direct property access |
| Template access | Auto-unwrapped (no .value) |
Direct |
| Destructuring | Safe (keeps reactivity) | Breaks reactivity — use toRefs |
// ref — best default choice
const count = ref(0)
const user = ref({ name: "Ana" })
count.value++
user.value.name = "Mia"
// reactive — use for complex objects you won't reassign
const state = reactive({ count: 0, name: "Ana" })
state.count++
// toRefs preserves reactivity when destructuring reactive objects
const { count: cnt, name } = toRefs(state)
Template directives cheat sheet
| Directive | Purpose | Example |
|---|---|---|
v-bind / : |
Bind prop/attr | :href="url" |
v-on / @ |
Listen to event | @click="handler" |
v-model |
Two-way binding | v-model="email" |
v-if / v-else-if / v-else |
Conditional render | v-if="isLoggedIn" |
v-show |
Toggle visibility (CSS) | v-show="isOpen" |
v-for |
List rendering | v-for="item in items" :key="item.id" |
v-once |
Render once, skip updates | v-once |
v-pre |
Skip compilation | v-pre |
v-if vs v-show:
v-ifremoves/inserts the DOM element — better when condition rarely changes.v-showtogglesdisplay:none— better for frequent toggling (keeps DOM alive).
Computed properties and watchers
const firstName = ref("Ana")
const lastName = ref("Marković")
// computed — cached, re-runs only when deps change
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
// watch — run side effects when data changes
watch(firstName, (newVal, oldVal) => {
console.log(`Changed from ${oldVal} to ${newVal}`)
})
// watchEffect — auto-tracks every reactive dep used inside
watchEffect(() => {
document.title = `Hello, ${firstName.value}`
})
Phase 3 — Component architecture
Props and emits
<!-- Child.vue -->
<script setup>
const props = defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 }
})
const emit = defineEmits(['update', 'delete'])
function handleClick() {
emit('update', props.count + 1)
}
</script>
<template>
<div>
<h2>{{ title }}</h2>
<button @click="handleClick">+1</button>
<button @click="emit('delete')">Delete</button>
</div>
</template>
<!-- Parent.vue -->
<script setup>
import Child from './Child.vue'
import { ref } from 'vue'
const myCount = ref(0)
</script>
<template>
<Child
title="My Counter"
:count="myCount"
@update="myCount = $event"
@delete="myCount = 0"
/>
</template>
Slots
<!-- Card.vue — provides named slots -->
<template>
<div class="card">
<header><slot name="header" /></header>
<main><slot /></main> <!-- default slot -->
<footer><slot name="footer" /></footer>
</div>
</template>
<!-- Usage -->
<Card>
<template #header><h2>Title</h2></template>
<p>Body content</p>
<template #footer><button>OK</button></template>
</Card>
Composables (reusable logic)
Composables are the Vue 3 equivalent of React hooks — extract stateful logic into reusable functions:
// composables/useFetch.js
import { ref } from 'vue'
export function useFetch(url) {
const data = ref(null)
const loading = ref(true)
const error = ref(null)
fetch(url)
.then(r => r.json())
.then(d => { data.value = d })
.catch(e => { error.value = e })
.finally(() => { loading.value = false })
return { data, loading, error }
}
// Component usage
const { data: users, loading } = useFetch('/api/users')
Component lifecycle
| Hook | When it runs |
|---|---|
onBeforeMount |
Before first render |
onMounted |
After DOM is ready — fetch data here |
onBeforeUpdate |
Before reactive update re-renders |
onUpdated |
After re-render |
onBeforeUnmount |
Before component removal — clean up timers |
onUnmounted |
After removal |
import { onMounted, onUnmounted } from 'vue'
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize) // always clean up!
})
Phase 4 — Vue Router
Vue Router is the official client-side routing solution.
npm install vue-router
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import About from '../views/About.vue'
import Profile from '../views/Profile.vue'
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
{ path: '/user/:id', component: Profile }, // dynamic segment
{ path: '/:pathMatch(.*)*', component: NotFound }, // 404
]
export default createRouter({
history: createWebHistory(),
routes,
})
<!-- Navigation -->
<RouterLink to="/">Home</RouterLink>
<RouterLink :to="{ name: 'profile', params: { id: 42 } }">Profile</RouterLink>
<!-- Route view renders matched component -->
<RouterView />
Navigation guards
// Route-level guard — protect authenticated pages
const routes = [
{
path: '/dashboard',
component: Dashboard,
meta: { requiresAuth: true }
}
]
router.beforeEach((to, from) => {
if (to.meta.requiresAuth && !isAuthenticated()) {
return { path: '/login', query: { redirect: to.fullPath } }
}
})
Programmatic navigation
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
// Read params
const userId = route.params.id
const search = route.query.q
// Navigate
router.push('/dashboard')
router.push({ name: 'profile', params: { id: 5 } })
router.replace('/login')
router.back()
Phase 5 — Pinia (state management)
Pinia is the official Vue state management library — it replaced Vuex in 2023.
npm install pinia
// stores/useAuthStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref(null)
const token = ref(localStorage.getItem('token'))
// Getters (computed)
const isLoggedIn = computed(() => !!token.value)
const userName = computed(() => user.value?.name ?? 'Guest')
// Actions
async function login(email, password) {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
headers: { 'Content-Type': 'application/json' }
})
const data = await res.json()
user.value = data.user
token.value = data.token
localStorage.setItem('token', data.token)
}
function logout() {
user.value = null
token.value = null
localStorage.removeItem('token')
}
return { user, token, isLoggedIn, userName, login, logout }
})
<!-- Using the store in a component -->
<script setup>
import { useAuthStore } from '@/stores/useAuthStore'
const auth = useAuthStore()
</script>
<template>
<div v-if="auth.isLoggedIn">
Hello, {{ auth.userName }}
<button @click="auth.logout">Logout</button>
</div>
<LoginForm v-else @submit="auth.login" />
</template>
Pinia vs Vuex vs local state
Local ref/reactive |
Pinia | Vuex | |
|---|---|---|---|
| Scope | Single component | Global / shared | Global / shared |
| Boilerplate | None | Minimal | High (mutations, actions, getters) |
| TypeScript | Native | Excellent | Moderate |
| DevTools | Partial | Full | Full |
| Vue 3 recommended | ✅ for local | ✅ for shared | ❌ legacy (Vue 2 era) |
Rule of thumb: use ref/reactive inside a single component; reach for Pinia only when two or more components need to share the same data.
Phase 6 — API integration & async patterns
useFetch composable with error handling
// composables/useApi.js
import { ref } from 'vue'
export function useApi() {
const loading = ref(false)
const error = ref(null)
async function request(url, options = {}) {
loading.value = true
error.value = null
try {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return await res.json()
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
return { loading, error, request }
}
Async components & Suspense
<script setup>
import { defineAsyncComponent } from 'vue'
// Load HeavyChart only when needed
const HeavyChart = defineAsyncComponent(() =>
import('./HeavyChart.vue')
)
</script>
<template>
<!-- Suspense shows fallback while async component loads -->
<Suspense>
<HeavyChart :data="chartData" />
<template #fallback>
<div>Loading chart…</div>
</template>
</Suspense>
</template>
Provide / Inject (avoid prop drilling)
// Parent (e.g., App.vue)
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme) // provide to all descendants
// Deep Child
import { inject } from 'vue'
const theme = inject('theme', 'light') // second arg = default
Phase 7 — TypeScript with Vue 3
Vue 3 was written in TypeScript and has first-class TS support.
# Create a TypeScript Vue project
npm create vue@latest my-app
# ✔ Add TypeScript? Yes
<script setup lang="ts">
interface User {
id: number
name: string
email: string
}
// Typed props
const props = defineProps<{
user: User
loading?: boolean
}>()
// Typed emits
const emit = defineEmits<{
update: [user: User]
delete: [id: number]
}>()
// Typed ref
const count = ref<number>(0)
const users = ref<User[]>([])
// Typed computed
const activeUsers = computed<User[]>(() =>
users.value.filter(u => u.id > 0)
)
// withDefaults for props with defaults
const propsWithDefaults = withDefaults(
defineProps<{ count?: number; label?: string }>(),
{ count: 0, label: 'Click me' }
)
</script>
Phase 8 — Testing
Testing pyramid for Vue
| Level | Tool | What to test |
|---|---|---|
| Unit | Vitest | Composables, stores, utilities |
| Component | Vue Test Utils + Vitest | Component render, events, props |
| E2E | Playwright or Cypress | Full user flows |
npm install -D vitest @vue/test-utils jsdom @vitejs/plugin-vue
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: { environment: 'jsdom' }
})
Component testing
// tests/Counter.test.js
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Counter from '@/components/Counter.vue'
describe('Counter', () => {
it('renders initial count', () => {
const wrapper = mount(Counter, { props: { initialCount: 5 } })
expect(wrapper.text()).toContain('5')
})
it('increments on button click', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('1')
})
it('emits update event', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('update')?.[0]).toEqual([1])
})
})
Testing Pinia stores
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from '@/stores/useAuthStore'
beforeEach(() => {
setActivePinia(createPinia())
})
it('login sets user and token', async () => {
const store = useAuthStore()
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => ({ user: { name: 'Ana' }, token: 'abc123' })
})
await store.login('ana@example.com', 'pass')
expect(store.isLoggedIn).toBe(true)
expect(store.userName).toBe('Ana')
})
Phase 9 — Nuxt.js
Nuxt.js is the full-stack meta-framework for Vue — it adds SSR, SSG, file-based routing, and auto-imports.
npx nuxi@latest init my-nuxt-app
cd my-nuxt-app && npm install
File-based routing
pages/
index.vue → /
about.vue → /about
blog/
index.vue → /blog
[slug].vue → /blog/:slug
user/
[id]/
profile.vue → /user/:id/profile
Rendering modes
| Mode | How | Best for |
|---|---|---|
| SSR (Server-Side Rendering) | Page generated on request | Dynamic, personalised pages |
| SSG (Static Site Generation) | Pre-rendered at build time | Blogs, docs, marketing |
| SPA | Client-only (no SSR) | Admin dashboards |
| ISR (Incremental Static Regen) | Revalidate cached pages | News, e-commerce |
<!-- pages/blog/[slug].vue -->
<script setup>
// useFetch is auto-imported in Nuxt
const { data: post } = await useFetch(`/api/posts/${useRoute().params.slug}`)
</script>
<template>
<article v-if="post">
<h1>{{ post.title }}</h1>
<div v-html="post.content" />
</article>
</template>
Nuxt auto-imports
Nuxt auto-imports Vue APIs, composables, and your own files — no manual imports needed:
<script setup>
// These are available WITHOUT any import statement in Nuxt:
const count = ref(0) // Vue ref
const route = useRoute() // Vue Router
const config = useRuntimeConfig() // Nuxt runtime config
// Your own composables/useFoo.js are also auto-imported
const { data } = useFetch('/api/items')
</script>
Phase 10 — DevOps & deployment
Docker
# Multi-stage Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
Deployment platforms
| Platform | Vue SPA | Nuxt SSR | Free tier | Best for |
|---|---|---|---|---|
| Vercel | ✅ | ✅ | ✅ | Full-stack Nuxt |
| Netlify | ✅ | ✅ | ✅ | JAMstack / SSG |
| Cloudflare Pages | ✅ | ✅ | ✅ | Global edge |
| Railway | ✅ | ✅ | Limited | Node SSR servers |
| AWS S3 + CloudFront | ✅ SPA only | ❌ | Pay-as-you-go | Enterprise |
GitHub Actions CI/CD
# .github/workflows/deploy.yml
name: Deploy Vue App
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm run test
- run: npm run build
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.ORG_ID }}
vercel-project-id: ${{ secrets.PROJECT_ID }}
vercel-args: '--prod'
Full Vue technology map
Language
JavaScript (ES2020+)
TypeScript ──────────────── type safety
UI Layer
Vue 3 SFC ─── <script setup> Composition API
Template directives (v-if, v-for, v-model…)
Reactivity (ref, reactive, computed, watch)
Composables (reusable stateful logic)
Routing
Vue Router ──────────────── client-side routing
Dynamic routes, nested routes
Navigation guards (auth, roles)
State Management
Pinia ───────────────────── global state
Local ref/reactive ──────── component-level state
API Layer
Fetch / Axios
TanStack Query (Vue Query) ─ server state cache
Ofetch (Nuxt built-in)
Styling
Tailwind CSS ────────────── utility-first
CSS Modules ─────────────── scoped per component
SCSS / PostCSS
Testing
Vitest ──────────────────── unit + component
Vue Test Utils ──────────── component mounting
Playwright ──────────────── E2E
Build Tools
Vite ────────────────────── dev server + bundler
Vue CLI (legacy, Webpack)
Meta-framework
Nuxt.js ─────────────────── SSR / SSG / full-stack
Nitro server engine
Auto-imports
Modules ecosystem
Package Management
pnpm (recommended) / npm / yarn
DevOps
Docker → Vercel / Netlify / Cloudflare Pages
GitHub Actions CI/CD
Realistic timeline (32 weeks)
| Weeks | Milestone | What you can build |
|---|---|---|
| 1–4 | JS fundamentals solid | Simple DOM projects |
| 5–8 | Vue 3 basics + reactivity | Counter, to-do list, simple forms |
| 9–11 | Component architecture | Reusable component library |
| 12–13 | Vue Router | Multi-page SPA |
| 14–15 | Pinia | SPA with shared state (shopping cart, auth) |
| 16–17 | API integration | CRUD app with REST backend |
| 18–20 | TypeScript | Refactor project to TS |
| 21–23 | Testing | 80%+ test coverage on existing project |
| 24–27 | Nuxt.js | Blog / portfolio with SSR/SSG |
| 28–29 | DevOps | Dockerized, deployed to Vercel with CI/CD |
| 30–32 | Portfolio polish + job search | 3 polished projects on GitHub |
Portfolio project ideas
| Project | Vue concepts practiced | Difficulty |
|---|---|---|
| Task Manager | v-for, v-model, Pinia, Vue Router | ⭐⭐ |
| Movie Search App | API calls, loading states, composables | ⭐⭐ |
| E-commerce Store | Cart state (Pinia), filters, checkout flow | ⭐⭐⭐ |
| Real-time Chat | WebSockets, reactive updates, auth | ⭐⭐⭐ |
| Blog with CMS | Nuxt.js, SSG, Markdown, SEO meta tags | ⭐⭐⭐ |
| Dashboard with Charts | Charts.js/ApexCharts, dynamic data, filters | ⭐⭐⭐⭐ |
Vue developer roles and salary
| Role | Level | US salary | EU salary |
|---|---|---|---|
| Junior Vue Developer | 0–2 yr | $60–80k | €30–45k |
| Mid-level Vue Developer | 2–5 yr | $85–115k | €50–75k |
| Senior Vue Developer | 5+ yr | $120–160k | €75–110k |
| Vue / Nuxt Full-Stack | 3+ yr | $100–140k | €65–95k |
| Front-End Lead (Vue) | 5+ yr | $130–170k | €85–120k |
| Vue Consultant / Freelance | 3+ yr | $80–150/hr | €60–120/hr |
Common mistakes table
| Mistake | Why it hurts | Fix |
|---|---|---|
| Using Vuex instead of Pinia in new projects | More boilerplate, worse TypeScript | Start with Pinia |
Forgetting :key on v-for |
Broken list updates, subtle bugs | Always use :key="item.id" |
| Mutating props directly | Breaks unidirectional data flow | Emit events to parent |
| Putting all state in Pinia | Over-engineered, harder to test | Local state for single-component data |
Missing onUnmounted cleanup |
Memory leaks (event listeners, timers) | Always pair onMounted with cleanup |
Using reactive and destructuring |
Loses reactivity silently | Use ref or toRefs |
Fetching data outside onMounted/composables |
SSR hydration mismatches in Nuxt | Use useFetch/useAsyncData in Nuxt |
| Skipping TypeScript | Harder to maintain as project grows | Add lang="ts" from day one |
Vue vs React vs Angular comparison
| Vue 3 | React 18 | Angular 17 | |
|---|---|---|---|
| Learning curve | Gentle | Moderate | Steep |
| Syntax style | HTML-first (templates) | JS-first (JSX) | TypeScript-first |
| State management | Pinia (official) | Zustand / Redux (community) | NgRx / Signals (built-in) |
| Full-stack meta-framework | Nuxt.js | Next.js | Analog.js |
| TypeScript support | Excellent | Excellent | Mandatory |
| Bundle size (hello world) | ~13kB | ~45kB | ~75kB |
| Corporate adoption | Alibaba, GitLab, Xiaomi | Meta, Airbnb, Netflix | Google, Microsoft, enterprise |
| Job market (2025) | Strong (especially Asia/EU) | Strongest | Strong (enterprise) |
| Best for | Progressive enhancement, teams new to frameworks | Complex SPAs, large ecosystems | Enterprise, opinionated structure |
6 frequently asked questions
Q: Should I learn Vue 2 or Vue 3?
A: Vue 3 only. Vue 2 reached end-of-life in December 2023 and no longer receives security patches. The Composition API and <script setup> are the current standard.
Q: Pinia or Vuex?
A: Pinia for all new projects. The Vue core team officially recommends Pinia as Vuex's successor. Pinia has less boilerplate, better TypeScript support, and first-class DevTools integration.
Q: Vue or React — which should I learn first?
A: Vue has a gentler learning curve and cleaner template syntax, making it a better first framework for most beginners. That said, React has the largest job market in North America. If you are already comfortable with one, the other takes 1–2 weeks to pick up.
Q: Do I need Nuxt.js to build a production Vue app?
A: Not always. For SPAs (admin dashboards, internal tools), plain Vue + Vite is sufficient. Add Nuxt when you need SSR for SEO, SSG for a static blog, or a full-stack API layer. Start without Nuxt and add it when you hit its use cases.
Q: Is Vue still popular in 2025?
A: Yes. Vue is consistently in the top 3 JavaScript frameworks by usage, particularly dominant in China and Europe. It is the framework of choice at GitLab, Alibaba, Xiaomi, and hundreds of mid-size companies that value its approachability.
Q: What's the difference between Options API and Composition API?
A: Options API (the Vue 2 style) organises code by option type (data, methods, computed). Composition API (Vue 3 standard) organises code by feature using functions — this makes composables possible and keeps related code together. New projects should use Composition API with <script setup>.