Toolmingo
Guides23 min read

Vue.js Tutorial for Beginners (2025) — Learn Vue 3 Step by Step

Complete Vue.js 3 tutorial for beginners. Learn Vue from scratch: setup, components, directives, reactivity, Composition API, Pinia, Vue Router, and build 3 real projects.

Vue.js Tutorial for Beginners (2025)

Vue.js is a progressive JavaScript framework for building user interfaces. It's designed to be easy to learn, yet powerful enough for complex applications.

This tutorial takes you from zero to building real Vue 3 apps with the Composition API.

Why Learn Vue.js?

Reason Detail
Easy to learn Gentle learning curve vs React/Angular
Excellent docs Best-in-class official documentation
Composition API Reusable, composable logic (Vue 3)
Reactivity Fine-grained reactivity out of the box
Performance ~40KB gzipped, fast virtual DOM
Community 45k+ GitHub stars, thriving ecosystem
Job market High demand, especially in Asia/Europe
Versatile SPAs, SSR (Nuxt), static sites, micro-frontends

Vue vs React vs Angular

Feature Vue 3 React 18 Angular 17
Learning curve Low Medium High
Language HTML/CSS/JS JSX + JS TypeScript
Reactivity Automatic Manual hooks Automatic (Signals)
Bundle size ~40KB ~45KB ~75KB
State Pinia Redux/Zustand NgRx/Services
Routing Vue Router React Router Built-in
SSR Nuxt Next.js Angular Universal
Backed by Community Meta Google
Best for Any size app React-heavy teams Enterprise

1. Setup

Option A: CDN (no install needed)

<!DOCTYPE html>
<html>
<head>
  <title>Vue App</title>
</head>
<body>
  <div id="app">{{ message }}</div>

  <script type="module">
    import { createApp, ref } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'

    createApp({
      setup() {
        const message = ref('Hello, Vue!')
        return { message }
      }
    }).mount('#app')
  </script>
</body>
</html>

Option B: Vite (recommended for real projects)

npm create vue@latest my-app
# Choose: TypeScript? No  JSX? No  Vue Router? Yes  Pinia? Yes  Vitest? No
cd my-app
npm install
npm run dev

Your app runs at http://localhost:5173.

Project Structure

my-app/
├── src/
│   ├── assets/          # Static files (images, CSS)
│   ├── components/      # Reusable Vue components
│   ├── views/           # Page components (with Vue Router)
│   ├── router/          # Route definitions
│   │   └── index.ts
│   ├── stores/          # Pinia stores
│   ├── App.vue          # Root component
│   └── main.ts          # App entry point
├── public/              # Public assets (not processed by Vite)
├── index.html
├── package.json
└── vite.config.ts

2. Single File Components (SFC)

Vue apps are built with .vue files — each file has three parts:

<!-- src/components/HelloWorld.vue -->
<template>
  <div class="hello">
    <h1>{{ title }}</h1>
    <p>Count: {{ count }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

// Props
const props = defineProps({
  title: {
    type: String,
    default: 'Hello, Vue!'
  }
})

// Reactive state
const count = ref(0)

// Method
function increment() {
  count.value++
}
</script>

<style scoped>
.hello {
  padding: 20px;
  text-align: center;
}
</style>

Three sections:

  • <template> — HTML structure (what to render)
  • <script setup> — Composition API logic
  • <style scoped> — CSS scoped to this component only

3. Reactivity: ref and reactive

Vue 3 reactivity is the heart of the framework.

ref — for primitive values

<script setup>
import { ref } from 'vue'

const count = ref(0)
const name = ref('Alice')
const isVisible = ref(true)

// In script: access via .value
console.log(count.value)  // 0
count.value++
console.log(count.value)  // 1
</script>

<template>
  <!-- In template: no .value needed -->
  <p>{{ count }}</p>
  <p>{{ name }}</p>
  <p v-if="isVisible">I am visible</p>
</template>

reactive — for objects

<script setup>
import { reactive } from 'vue'

const user = reactive({
  name: 'Alice',
  age: 30,
  address: {
    city: 'Paris'
  }
})

// Access directly (no .value)
user.name = 'Bob'
user.address.city = 'London'
</script>

<template>
  <p>{{ user.name }} lives in {{ user.address.city }}</p>
</template>

ref vs reactive

ref reactive
Use for Primitives, any value Objects, arrays
Access in script .value required Direct access
Access in template Auto-unwrapped Direct access
Can reassign Yes (x.value = newObj) No (loses reactivity)
Destructuring Safe Loses reactivity (use toRefs)

Recommendation: Use ref for everything — simpler and consistent.

computed — derived reactive values

<script setup>
import { ref, computed } from 'vue'

const firstName = ref('Alice')
const lastName = ref('Smith')

// Computed value — auto-updates when dependencies change
const fullName = computed(() => `${firstName.value} ${lastName.value}`)

const price = ref(100)
const discount = ref(20)
const finalPrice = computed(() => price.value * (1 - discount.value / 100))
</script>

<template>
  <p>Full name: {{ fullName }}</p>
  <p>Final price: ${{ finalPrice.toFixed(2) }}</p>
</template>

watch — side effects on change

<script setup>
import { ref, watch, watchEffect } from 'vue'

const count = ref(0)
const query = ref('')

// watch — explicit dependency
watch(count, (newVal, oldVal) => {
  console.log(`count changed from ${oldVal} to ${newVal}`)
})

// watch multiple sources
watch([count, query], ([newCount, newQuery]) => {
  console.log('either changed:', newCount, newQuery)
})

// watchEffect — auto-tracks all accessed refs
watchEffect(() => {
  // runs immediately, re-runs when query changes
  console.log('searching for:', query.value)
})
</script>

4. Template Syntax

Text interpolation

<template>
  <p>{{ message }}</p>
  <p>{{ 1 + 1 }}</p>
  <p>{{ user.name.toUpperCase() }}</p>
  <p>{{ isLoggedIn ? 'Welcome!' : 'Please login' }}</p>
</template>

Directives — cheat sheet

Directive Purpose Example
v-bind:attr or :attr Bind attribute :href="url"
v-on:event or @event Event listener @click="handler"
v-model Two-way binding v-model="inputVal"
v-if / v-else-if / v-else Conditional render v-if="isAdmin"
v-show Toggle visibility v-show="isOpen"
v-for List rendering v-for="item in list"
v-once Render once v-once
v-html Raw HTML v-html="htmlStr"
v-pre Skip compilation v-pre

v-bind — attribute binding

<script setup>
const imgUrl = ref('/logo.png')
const isActive = ref(true)
const styles = ref({ color: 'red', fontSize: '16px' })
</script>

<template>
  <!-- Bind attribute -->
  <img :src="imgUrl" :alt="'Logo'" />

  <!-- Bind class -->
  <div :class="{ active: isActive, disabled: !isActive }">Text</div>

  <!-- Multiple classes -->
  <div :class="['base-class', isActive ? 'active' : '']">Text</div>

  <!-- Bind style -->
  <p :style="styles">Styled</p>
  <p :style="{ color: 'blue', fontWeight: 'bold' }">Blue bold</p>
</template>

v-on — event handling

<script setup>
function handleClick() {
  console.log('clicked!')
}

function handleInput(event) {
  console.log(event.target.value)
}

function handleSubmit(event) {
  event.preventDefault()
  console.log('submitted!')
}
</script>

<template>
  <!-- Basic event -->
  <button @click="handleClick">Click me</button>

  <!-- Inline handler -->
  <button @click="count++">Increment</button>

  <!-- Event with argument -->
  <button @click="handleInput($event)">Input</button>

  <!-- Event modifiers -->
  <form @submit.prevent="handleSubmit">
    <button type="submit">Submit</button>
  </form>

  <!-- Key modifiers -->
  <input @keyup.enter="search" @keyup.esc="clear" />

  <!-- Mouse modifiers -->
  <div @click.right.prevent="showMenu">Right-click me</div>
</template>

Event modifiers:

Modifier Equivalent
.prevent event.preventDefault()
.stop event.stopPropagation()
.once Fire once
.capture Capture phase
.self Only if target is element itself
.passive Improves scroll performance

v-model — two-way binding

<script setup>
import { ref } from 'vue'

const text = ref('')
const checked = ref(false)
const selected = ref('')
const multiSelect = ref([])
</script>

<template>
  <!-- Text input -->
  <input v-model="text" placeholder="Type here..." />
  <p>You typed: {{ text }}</p>

  <!-- Checkbox -->
  <input type="checkbox" v-model="checked" />
  <span>{{ checked }}</span>

  <!-- Select -->
  <select v-model="selected">
    <option value="">Choose...</option>
    <option value="vue">Vue</option>
    <option value="react">React</option>
  </select>

  <!-- Multi-select (returns array) -->
  <select v-model="multiSelect" multiple>
    <option value="a">A</option>
    <option value="b">B</option>
  </select>
</template>

v-model modifiers:

<!-- Trim whitespace -->
<input v-model.trim="name" />

<!-- Cast to number -->
<input v-model.number="age" type="number" />

<!-- Update on change (not input) -->
<input v-model.lazy="text" />

v-if and v-show

<script setup>
const isLoggedIn = ref(true)
const userRole = ref('admin')
const items = ref([])
</script>

<template>
  <!-- v-if: element removed from DOM -->
  <div v-if="isLoggedIn">
    <p v-if="userRole === 'admin'">Admin panel</p>
    <p v-else-if="userRole === 'editor'">Editor panel</p>
    <p v-else>Viewer panel</p>
  </div>
  <div v-else>
    <p>Please log in</p>
  </div>

  <!-- v-show: element hidden with CSS (stays in DOM) -->
  <p v-show="isLoggedIn">Visible when logged in</p>

  <!-- Group elements with template -->
  <template v-if="items.length > 0">
    <h3>Items:</h3>
    <ul>
      <li v-for="item in items" :key="item.id">{{ item.name }}</li>
    </ul>
  </template>
  <p v-else>No items found.</p>
</template>

v-if vs v-show:

v-if v-show
DOM Removed/added Always in DOM
CSS N/A display: none
Initial render Lazy (skips if false) Always renders
Toggle cost Higher Lower
Use when Rarely toggles Frequently toggles

v-for — list rendering

<script setup>
const fruits = ref(['Apple', 'Banana', 'Cherry'])

const users = ref([
  { id: 1, name: 'Alice', role: 'admin' },
  { id: 2, name: 'Bob', role: 'user' },
])

const settings = ref({
  theme: 'dark',
  language: 'en',
  notifications: true
})
</script>

<template>
  <!-- Array -->
  <ul>
    <li v-for="(fruit, index) in fruits" :key="index">
      {{ index + 1 }}. {{ fruit }}
    </li>
  </ul>

  <!-- Array of objects — always use :key with unique id -->
  <div v-for="user in users" :key="user.id">
    <strong>{{ user.name }}</strong> — {{ user.role }}
  </div>

  <!-- Object -->
  <ul>
    <li v-for="(value, key) in settings" :key="key">
      {{ key }}: {{ value }}
    </li>
  </ul>

  <!-- Range -->
  <span v-for="n in 5" :key="n">{{ n }} </span>
  <!-- renders: 1 2 3 4 5 -->
</template>

5. Components

Creating and using components

<!-- src/components/UserCard.vue -->
<template>
  <div class="card">
    <img :src="avatar" :alt="name" />
    <h3>{{ name }}</h3>
    <p>{{ bio }}</p>
  </div>
</template>

<script setup>
defineProps({
  name: {
    type: String,
    required: true
  },
  avatar: {
    type: String,
    default: '/default-avatar.png'
  },
  bio: {
    type: String,
    default: ''
  }
})
</script>
<!-- src/App.vue -->
<script setup>
import UserCard from './components/UserCard.vue'
</script>

<template>
  <UserCard
    name="Alice Smith"
    avatar="/alice.jpg"
    bio="Full-stack developer from Paris"
  />
  <UserCard name="Bob Jones" />
</template>

Props — parent to child

<!-- Child component -->
<script setup>
// Simple props
const props = defineProps({
  title: String,
  count: {
    type: Number,
    default: 0
  },
  isActive: {
    type: Boolean,
    default: false
  },
  items: {
    type: Array,
    default: () => []
  },
  config: {
    type: Object,
    validator: (val) => val.theme !== undefined
  }
})

// Access in script
console.log(props.title)
</script>

<template>
  <h2>{{ title }}</h2>
  <p>Count: {{ count }}</p>
</template>

Emits — child to parent

<!-- Child: src/components/SearchBar.vue -->
<script setup>
import { ref } from 'vue'

const emit = defineEmits(['search', 'clear'])

const query = ref('')

function handleSearch() {
  emit('search', query.value)
}

function handleClear() {
  query.value = ''
  emit('clear')
}
</script>

<template>
  <div>
    <input v-model="query" @keyup.enter="handleSearch" placeholder="Search..." />
    <button @click="handleSearch">Search</button>
    <button @click="handleClear">Clear</button>
  </div>
</template>
<!-- Parent -->
<script setup>
import SearchBar from './components/SearchBar.vue'

const results = ref([])

function onSearch(query) {
  console.log('Searching for:', query)
  // fetch results...
}

function onClear() {
  results.value = []
}
</script>

<template>
  <SearchBar @search="onSearch" @clear="onClear" />
</template>

Slots — flexible content

<!-- BaseCard.vue -->
<template>
  <div class="card">
    <!-- Named slot -->
    <header class="card-header">
      <slot name="header">Default Header</slot>
    </header>

    <!-- Default slot -->
    <main class="card-body">
      <slot>No content provided.</slot>
    </main>

    <!-- Named slot with fallback -->
    <footer class="card-footer">
      <slot name="footer">
        <button>Close</button>
      </slot>
    </footer>
  </div>
</template>
<!-- Using BaseCard -->
<template>
  <BaseCard>
    <template #header>
      <h2>My Custom Header</h2>
    </template>

    <p>This goes into the default slot.</p>
    <p>Multiple elements are fine.</p>

    <template #footer>
      <button @click="save">Save</button>
      <button @click="cancel">Cancel</button>
    </template>
  </BaseCard>
</template>

6. Lifecycle Hooks

<script setup>
import { ref, onMounted, onUpdated, onUnmounted, onBeforeMount } from 'vue'

const data = ref(null)

onBeforeMount(() => {
  // Before component is mounted (DOM not available yet)
  console.log('Before mount')
})

onMounted(() => {
  // Component is in the DOM — fetch data, start timers, access DOM
  console.log('Mounted')
  fetchData()
})

onUpdated(() => {
  // After reactive data changed and DOM updated
  console.log('Updated')
})

onUnmounted(() => {
  // Cleanup — clear timers, cancel requests, remove listeners
  console.log('Unmounted')
})

async function fetchData() {
  const res = await fetch('https://api.example.com/data')
  data.value = await res.json()
}
</script>

Lifecycle order:

Hook When Use for
onBeforeMount Before DOM creation Rarely needed
onMounted After DOM inserted Fetch data, DOM access, timers
onBeforeUpdate Before DOM re-render Compare old/new DOM
onUpdated After DOM re-render Access updated DOM
onBeforeUnmount Before cleanup Prepare teardown
onUnmounted After cleanup Clear timers, cancel subscriptions

7. Composables (Custom Hooks)

Composables are reusable logic functions — Vue's equivalent of React hooks.

// src/composables/useFetch.js
import { ref, watchEffect } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)

  async function fetchData() {
    loading.value = true
    error.value = null

    try {
      const res = await fetch(url.value ?? url)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      data.value = await res.json()
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }

  watchEffect(() => {
    fetchData()
  })

  return { data, loading, error, refetch: fetchData }
}
<!-- Using the composable -->
<script setup>
import { useFetch } from '@/composables/useFetch'

const { data: posts, loading, error } = useFetch('https://jsonplaceholder.typicode.com/posts')
</script>

<template>
  <div v-if="loading">Loading...</div>
  <div v-else-if="error">Error: {{ error }}</div>
  <ul v-else>
    <li v-for="post in posts?.slice(0, 5)" :key="post.id">
      {{ post.title }}
    </li>
  </ul>
</template>

More composable examples

// src/composables/useLocalStorage.js
import { ref, watch } from 'vue'

export function useLocalStorage(key, defaultValue) {
  const stored = localStorage.getItem(key)
  const value = ref(stored ? JSON.parse(stored) : defaultValue)

  watch(value, (newVal) => {
    localStorage.setItem(key, JSON.stringify(newVal))
  }, { deep: true })

  return value
}
// src/composables/useCounter.js
import { ref, computed } from 'vue'

export function useCounter(initialValue = 0, options = {}) {
  const { min = -Infinity, max = Infinity } = options
  const count = ref(initialValue)

  const increment = () => { if (count.value < max) count.value++ }
  const decrement = () => { if (count.value > min) count.value-- }
  const reset = () => { count.value = initialValue }

  const isAtMax = computed(() => count.value >= max)
  const isAtMin = computed(() => count.value <= min)

  return { count, increment, decrement, reset, isAtMax, isAtMin }
}

8. Vue Router

npm install vue-router@4

Define routes

// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
import UserProfile from '@/views/UserProfile.vue'
import NotFound from '@/views/NotFound.vue'

const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About },

  // Route with parameter
  { path: '/users/:id', component: UserProfile },

  // Nested routes
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'), // lazy load
    children: [
      { path: '', component: () => import('@/views/DashboardHome.vue') },
      { path: 'settings', component: () => import('@/views/Settings.vue') },
    ]
  },

  // Redirect
  { path: '/home', redirect: '/' },

  // Catch-all 404
  { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound },
]

const router = createRouter({
  history: createWebHistory(),
  routes,
})

export default router
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App).use(router).mount('#app')

Navigation

<script setup>
import { useRouter, useRoute } from 'vue-router'

const router = useRouter()
const route = useRoute()

// Current route info
console.log(route.path)       // '/users/42'
console.log(route.params.id)  // '42'
console.log(route.query)      // { tab: 'settings' }

// Programmatic navigation
function goHome() {
  router.push('/')
}

function goToUser(id) {
  router.push({ path: `/users/${id}` })
  // or
  router.push({ name: 'user', params: { id } })
}

function goBack() {
  router.back()
}
</script>

<template>
  <!-- RouterLink for declarative navigation -->
  <nav>
    <RouterLink to="/">Home</RouterLink>
    <RouterLink to="/about">About</RouterLink>
    <RouterLink :to="`/users/${userId}`">Profile</RouterLink>

    <!-- Active class applied automatically -->
    <RouterLink to="/dashboard" active-class="is-active" exact-active-class="is-exact">
      Dashboard
    </RouterLink>
  </nav>

  <!-- Router outlet -->
  <RouterView />
</template>

Route guards

// Navigation guard — auth protection
router.beforeEach((to, from, next) => {
  const isAuthenticated = !!localStorage.getItem('token')

  if (to.meta.requiresAuth && !isAuthenticated) {
    next('/login')
  } else {
    next()
  }
})

// Per-route guard
const routes = [
  {
    path: '/admin',
    component: AdminPanel,
    meta: { requiresAuth: true, role: 'admin' },
    beforeEnter: (to, from, next) => {
      // component-specific guard
      next()
    }
  }
]

9. Pinia (State Management)

Pinia is the official Vue state management library. Simple, intuitive, TypeScript-first.

npm install pinia
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

createApp(App).use(createPinia()).mount('#app')

Define a store

// src/stores/useCartStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// Option 1: Composition API store (recommended)
export const useCartStore = defineStore('cart', () => {
  // State
  const items = ref([])

  // Getters (computed)
  const totalItems = computed(() => items.value.reduce((sum, item) => sum + item.qty, 0))
  const totalPrice = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.qty, 0)
  )

  // Actions
  function addItem(product) {
    const existing = items.value.find(i => i.id === product.id)
    if (existing) {
      existing.qty++
    } else {
      items.value.push({ ...product, qty: 1 })
    }
  }

  function removeItem(productId) {
    items.value = items.value.filter(i => i.id !== productId)
  }

  function clearCart() {
    items.value = []
  }

  return { items, totalItems, totalPrice, addItem, removeItem, clearCart }
})
<!-- Using the store -->
<script setup>
import { useCartStore } from '@/stores/useCartStore'

const cart = useCartStore()

const product = { id: 1, name: 'Vue Book', price: 29.99 }
</script>

<template>
  <div>
    <p>Items in cart: {{ cart.totalItems }}</p>
    <p>Total: ${{ cart.totalPrice.toFixed(2) }}</p>

    <button @click="cart.addItem(product)">Add to Cart</button>
    <button @click="cart.clearCart()">Clear Cart</button>

    <ul>
      <li v-for="item in cart.items" :key="item.id">
        {{ item.name }} × {{ item.qty }}
        <button @click="cart.removeItem(item.id)">Remove</button>
      </li>
    </ul>
  </div>
</template>

10. Handling HTTP Requests

Using fetch (built-in)

<script setup>
import { ref, onMounted } from 'vue'

const posts = ref([])
const loading = ref(false)
const error = ref(null)

onMounted(async () => {
  loading.value = true
  try {
    const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5')
    if (!res.ok) throw new Error('Request failed')
    posts.value = await res.json()
  } catch (err) {
    error.value = err.message
  } finally {
    loading.value = false
  }
})
</script>

<template>
  <div v-if="loading">Loading posts...</div>
  <div v-else-if="error" class="error">{{ error }}</div>
  <ul v-else>
    <li v-for="post in posts" :key="post.id">
      <strong>{{ post.title }}</strong>
    </li>
  </ul>
</template>

Using axios

npm install axios
// src/services/api.js
import axios from 'axios'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'https://api.example.com',
  timeout: 10000,
})

// Request interceptor — add auth token
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('token')
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})

// Response interceptor — handle errors
api.interceptors.response.use(
  (response) => response.data,
  (error) => {
    if (error.response?.status === 401) {
      // redirect to login
    }
    return Promise.reject(error)
  }
)

export default api

11. Forms with Validation

<script setup>
import { ref, reactive, computed } from 'vue'

const form = reactive({
  name: '',
  email: '',
  password: '',
})

const errors = reactive({
  name: '',
  email: '',
  password: '',
})

const submitted = ref(false)

function validateEmail(email) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}

function validate() {
  let isValid = true

  errors.name = ''
  errors.email = ''
  errors.password = ''

  if (!form.name.trim()) {
    errors.name = 'Name is required'
    isValid = false
  } else if (form.name.length < 2) {
    errors.name = 'Name must be at least 2 characters'
    isValid = false
  }

  if (!form.email) {
    errors.email = 'Email is required'
    isValid = false
  } else if (!validateEmail(form.email)) {
    errors.email = 'Enter a valid email'
    isValid = false
  }

  if (!form.password) {
    errors.password = 'Password is required'
    isValid = false
  } else if (form.password.length < 8) {
    errors.password = 'Password must be at least 8 characters'
    isValid = false
  }

  return isValid
}

function handleSubmit() {
  if (validate()) {
    submitted.value = true
    console.log('Form submitted:', form)
  }
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <div class="field">
      <label>Name</label>
      <input v-model="form.name" type="text" placeholder="Your name" />
      <span class="error" v-if="errors.name">{{ errors.name }}</span>
    </div>

    <div class="field">
      <label>Email</label>
      <input v-model="form.email" type="email" placeholder="you@example.com" />
      <span class="error" v-if="errors.email">{{ errors.email }}</span>
    </div>

    <div class="field">
      <label>Password</label>
      <input v-model="form.password" type="password" placeholder="Min 8 chars" />
      <span class="error" v-if="errors.password">{{ errors.password }}</span>
    </div>

    <button type="submit">Register</button>

    <p v-if="submitted" class="success">Registration successful!</p>
  </form>
</template>

12. Project 1 — Todo App

<!-- src/App.vue -->
<script setup>
import { ref, computed } from 'vue'

const todos = ref([
  { id: 1, text: 'Learn Vue 3', done: true },
  { id: 2, text: 'Build an app', done: false },
])

const newTodo = ref('')
const filter = ref('all') // 'all' | 'active' | 'done'

const filteredTodos = computed(() => {
  if (filter.value === 'active') return todos.value.filter(t => !t.done)
  if (filter.value === 'done') return todos.value.filter(t => t.done)
  return todos.value
})

const remaining = computed(() => todos.value.filter(t => !t.done).length)

function addTodo() {
  if (!newTodo.value.trim()) return

  todos.value.push({
    id: Date.now(),
    text: newTodo.value.trim(),
    done: false,
  })
  newTodo.value = ''
}

function toggleTodo(id) {
  const todo = todos.value.find(t => t.id === id)
  if (todo) todo.done = !todo.done
}

function removeTodo(id) {
  todos.value = todos.value.filter(t => t.id !== id)
}

function clearDone() {
  todos.value = todos.value.filter(t => !t.done)
}
</script>

<template>
  <div class="app">
    <h1>Todo App</h1>

    <form @submit.prevent="addTodo">
      <input
        v-model="newTodo"
        placeholder="What needs to be done?"
        autofocus
      />
      <button type="submit">Add</button>
    </form>

    <div class="filters">
      <button
        v-for="f in ['all', 'active', 'done']"
        :key="f"
        :class="{ active: filter === f }"
        @click="filter = f"
      >
        {{ f.charAt(0).toUpperCase() + f.slice(1) }}
      </button>
    </div>

    <ul>
      <li
        v-for="todo in filteredTodos"
        :key="todo.id"
        :class="{ done: todo.done }"
      >
        <input type="checkbox" :checked="todo.done" @change="toggleTodo(todo.id)" />
        <span>{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)">✕</button>
      </li>
    </ul>

    <p>{{ remaining }} task{{ remaining !== 1 ? 's' : '' }} remaining</p>
    <button v-if="todos.some(t => t.done)" @click="clearDone">
      Clear completed
    </button>
  </div>
</template>

<style scoped>
.app { max-width: 500px; margin: 40px auto; padding: 0 20px; font-family: sans-serif; }
input[type="text"] { width: 100%; padding: 10px; margin-bottom: 10px; }
.filters button { margin-right: 8px; }
.filters button.active { font-weight: bold; }
li.done span { text-decoration: line-through; opacity: 0.5; }
li { display: flex; align-items: center; gap: 10px; padding: 8px 0; }
</style>

13. Project 2 — Weather App

<!-- src/App.vue -->
<script setup>
import { ref } from 'vue'

const city = ref('')
const weather = ref(null)
const loading = ref(false)
const error = ref('')

// Uses Open-Meteo geocoding + weather API (no API key needed)
async function fetchWeather() {
  if (!city.value.trim()) return

  loading.value = true
  error.value = ''
  weather.value = null

  try {
    // Step 1: Geocode city name
    const geoRes = await fetch(
      `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city.value)}&count=1`
    )
    const geoData = await geoRes.json()

    if (!geoData.results?.length) {
      error.value = 'City not found'
      return
    }

    const { latitude, longitude, name, country } = geoData.results[0]

    // Step 2: Fetch weather
    const weatherRes = await fetch(
      `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}` +
      `&current_weather=true&hourly=temperature_2m,precipitation_probability,weathercode` +
      `&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum` +
      `&timezone=auto&forecast_days=5`
    )
    const weatherData = await weatherRes.json()

    weather.value = {
      city: name,
      country,
      current: weatherData.current_weather,
      daily: weatherData.daily,
    }
  } catch (err) {
    error.value = 'Failed to fetch weather. Try again.'
  } finally {
    loading.value = false
  }
}

function getWeatherEmoji(code) {
  if (code === 0) return '☀️'
  if (code <= 3) return '⛅'
  if (code <= 67) return '🌧️'
  if (code <= 77) return '❄️'
  if (code <= 82) return '🌦️'
  return '⛈️'
}

function formatDate(dateStr) {
  return new Date(dateStr).toLocaleDateString('en', { weekday: 'short', month: 'short', day: 'numeric' })
}
</script>

<template>
  <div class="weather-app">
    <h1>🌤️ Weather App</h1>

    <form @submit.prevent="fetchWeather">
      <input v-model="city" placeholder="Enter city name..." />
      <button type="submit" :disabled="loading">
        {{ loading ? 'Loading...' : 'Search' }}
      </button>
    </form>

    <p v-if="error" class="error">{{ error }}</p>

    <div v-if="weather" class="result">
      <div class="current">
        <h2>{{ weather.city }}, {{ weather.country }}</h2>
        <div class="temp">
          <span class="emoji">{{ getWeatherEmoji(weather.current.weathercode) }}</span>
          <span class="degrees">{{ Math.round(weather.current.temperature) }}°C</span>
        </div>
        <p>Wind: {{ weather.current.windspeed }} km/h</p>
      </div>

      <div class="forecast">
        <h3>5-Day Forecast</h3>
        <div class="days">
          <div
            v-for="(date, i) in weather.daily.time"
            :key="date"
            class="day"
          >
            <div>{{ i === 0 ? 'Today' : formatDate(date) }}</div>
            <div class="emoji">{{ getWeatherEmoji(weather.daily.weathercode[i]) }}</div>
            <div>{{ Math.round(weather.daily.temperature_2m_max[i]) }}° / {{ Math.round(weather.daily.temperature_2m_min[i]) }}°</div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.weather-app { max-width: 600px; margin: 40px auto; padding: 20px; font-family: sans-serif; }
input { padding: 10px; font-size: 16px; width: 70%; }
button { padding: 10px 20px; font-size: 16px; margin-left: 8px; }
.current { text-align: center; margin: 20px 0; }
.temp { display: flex; align-items: center; justify-content: center; gap: 10px; }
.emoji { font-size: 2rem; }
.degrees { font-size: 3rem; font-weight: bold; }
.days { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
.day { text-align: center; padding: 10px; background: #f0f0f0; border-radius: 8px; min-width: 80px; }
.error { color: red; }
</style>

14. Project 3 — Multi-Page App with Router + Pinia

# Create project with router + pinia
npm create vue@latest notes-app
# Select: Router: Yes, Pinia: Yes
// src/stores/useNotesStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useNotesStore = defineStore('notes', () => {
  const notes = ref(JSON.parse(localStorage.getItem('notes') ?? '[]'))
  const searchQuery = ref('')

  const filteredNotes = computed(() => {
    if (!searchQuery.value) return notes.value
    const q = searchQuery.value.toLowerCase()
    return notes.value.filter(n =>
      n.title.toLowerCase().includes(q) ||
      n.content.toLowerCase().includes(q)
    )
  })

  function saveToStorage() {
    localStorage.setItem('notes', JSON.stringify(notes.value))
  }

  function addNote(title, content) {
    notes.value.unshift({
      id: Date.now(),
      title,
      content,
      createdAt: new Date().toISOString(),
    })
    saveToStorage()
  }

  function updateNote(id, title, content) {
    const note = notes.value.find(n => n.id === id)
    if (note) {
      note.title = title
      note.content = content
      note.updatedAt = new Date().toISOString()
      saveToStorage()
    }
  }

  function deleteNote(id) {
    notes.value = notes.value.filter(n => n.id !== id)
    saveToStorage()
  }

  function getNoteById(id) {
    return notes.value.find(n => n.id === Number(id))
  }

  return { notes, filteredNotes, searchQuery, addNote, updateNote, deleteNote, getNoteById }
})
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'

export default createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: () => import('@/views/HomeView.vue') },
    { path: '/notes/new', component: () => import('@/views/NewNoteView.vue') },
    { path: '/notes/:id', component: () => import('@/views/NoteView.vue') },
    { path: '/notes/:id/edit', component: () => import('@/views/EditNoteView.vue') },
  ]
})
<!-- src/views/HomeView.vue -->
<script setup>
import { useNotesStore } from '@/stores/useNotesStore'
import { useRouter } from 'vue-router'

const store = useNotesStore()
const router = useRouter()

function formatDate(iso) {
  return new Date(iso).toLocaleDateString('en', {
    year: 'numeric', month: 'short', day: 'numeric'
  })
}
</script>

<template>
  <div>
    <div class="header">
      <h1>Notes</h1>
      <RouterLink to="/notes/new">+ New Note</RouterLink>
    </div>

    <input v-model="store.searchQuery" placeholder="Search notes..." />

    <div v-if="store.filteredNotes.length === 0">
      <p>No notes yet. <RouterLink to="/notes/new">Create one!</RouterLink></p>
    </div>

    <div class="notes-grid">
      <RouterLink
        v-for="note in store.filteredNotes"
        :key="note.id"
        :to="`/notes/${note.id}`"
        class="note-card"
      >
        <h3>{{ note.title }}</h3>
        <p>{{ note.content.slice(0, 100) }}{{ note.content.length > 100 ? '...' : '' }}</p>
        <small>{{ formatDate(note.createdAt) }}</small>
      </RouterLink>
    </div>
  </div>
</template>

15. Common Mistakes

Mistake Problem Fix
Mutating reactive directly Loses reactivity Use ref or update properties, don't reassign
Forgetting .value ref not reactive in script Always use .value in <script setup>
No :key in v-for Poor performance, bugs Always add :key="unique-id"
v-if + v-for on same element Unexpected behavior Put v-if on wrapper <template>
Mutating props directly Breaks one-way data flow Emit event to parent, or use local copy
Watching reactive object shallowly Nested changes missed Add { deep: true } to watch
Calling composables outside setup() Breaks reactivity context Only call composables at top of setup
Not cleaning up in onUnmounted Memory leaks, stale data Clear timers, cancel subscriptions

Vue vs Related Terms

Term What it is
Vue 3 Latest major version — Composition API, <script setup>, Proxy-based reactivity
Vue 2 Legacy version — Options API, Vue.observable, EOL 2023
Vite Fast build tool (replaces Vue CLI for new projects)
Nuxt.js Vue framework for SSR, SSG, file-based routing
Pinia Official Vue state management (replaces Vuex)
Vuex Legacy state management for Vue 2/3 (use Pinia instead)
Vue Router Official routing library
Composition API Vue 3 way to write component logic (vs Options API)
Options API Vue 2 style (data(), methods, computed, mounted)
SFC Single File Component — .vue file with template/script/style

Learning Path

Stage Timeline Topics
1. Foundations Week 1–2 HTML/CSS/JS ES6+, arrow functions, destructuring, promises
2. Vue Basics Week 3–4 SFC, ref/reactive, directives, events, computed
3. Components Week 5–6 Props, emits, slots, composables, lifecycle hooks
4. Routing Week 7 Vue Router, params, guards, nested routes
5. State Week 8 Pinia stores, getters, actions
6. Real Projects Week 9–12 API integration, forms, validation, error handling
7. Nuxt.js Month 4+ SSR, SSG, file-based routing, Nitro server

Free Resources

Resource URL Best for
Official Docs vuejs.org Comprehensive reference
Vue School vueschool.io Video courses
Vue Mastery vuemastery.com Structured learning
Pinia Docs pinia.vuejs.org State management
Vue Router Docs router.vuejs.org Routing
Nuxt Docs nuxt.com Full-stack Vue

FAQ

Is Vue.js good for beginners?

Yes — Vue has the most gentle learning curve of the major frameworks. You can start with just HTML, add a Vue CDN link, and build interactive apps without a build step. The Composition API is intuitive once you understand ref.

Vue 2 or Vue 3?

Always Vue 3 for new projects. Vue 2 reached end-of-life on December 31, 2023 and no longer receives security updates. Vue 3 has better performance, TypeScript support, and the Composition API.

Options API or Composition API?

Use Composition API (<script setup>) — it's the modern Vue 3 approach. Better TypeScript support, more reusable logic through composables, and better tree-shaking. Options API still works and is fine for smaller projects.

Vue vs React — which should I learn?

Vue is easier to learn. React has a larger ecosystem and job market (especially in the US). Both are excellent choices. Vue is dominant in Asia and growing in Europe. Start with whichever excites you more.

Do I need Pinia for every Vue app?

No — ref and reactive in a composable is enough for small/medium apps. Add Pinia when you need to share state across many components that aren't in a parent-child relationship.

What's Nuxt.js and do I need it?

Nuxt.js adds Server-Side Rendering (SSR), Static Site Generation (SSG), file-based routing, and server API routes to Vue. Use it when SEO matters, for content-heavy sites, or for full-stack apps. For pure SPAs (dashboards, tools), plain Vue + Vite is fine.

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools