VOOZH about

URL: https://tech-insider.org/vuejs-tutorial-full-stack-app-pinia-2026/

⇱ Vue.js 3 Tutorial: Build a Full-Stack App with Pinia (2026)


Skip to content
April 2, 2026
24 min read

Published: April 2, 2026  |  Vue.js Version: 3.5.31  |  Reading Time: ~35 minutes

This vue.js tutorial walks you through building a production-ready Task Manager application from scratch using Vue.js 3, Pinia, Vue Router, TypeScript, and Tailwind CSS. By the end of this guide, you will have a fully functional full-stack-ready frontend application with state management, client-side routing, persistent storage, and a complete test suite – skills that transfer directly to real-world projects.

Vue.js 3 continues to dominate developer satisfaction surveys in 2026. The framework’s Composition API, combined with Pinia’s intuitive store model and Vite 8’s blazing-fast Rolldown-powered builds, makes the Vue ecosystem one of the most productive choices for modern web development. Whether you are migrating from Vue 2, switching from React (see our React vs Vue 2026 comparison), or starting fresh, this guide covers what you need.

Vue 3.5.31 landed on March 25, 2026, bringing incremental stability improvements, while Vue 3.6.0-beta.9 (available as of April 2026) previews alien signals – a ground-up reactivity performance rewrite – alongside the actively iterated Vapor Mode, a compilation strategy that eliminates the virtual DOM for hot-path components. These advancements signal that Vue’s performance trajectory is steeper than ever, making now an excellent time to invest deeply in the ecosystem.

If you are evaluating frameworks before committing to this tutorial, our Angular vs React 2026 breakdown provides useful context for understanding where Vue sits in the current landscape. For a full-stack perspective on a comparable framework, see our Next.js full-stack tutorial. And if you are curious about the AI-assisted coding tools that can accelerate the workflow described here, check out our complete AI coding tools guide.

Prerequisites and Environment Setup

Before diving into this vue 3 tutorial, confirm that your development environment meets the following requirements. Using outdated tooling is one of the most common sources of confusing errors, so it is worth spending five minutes verifying versions before writing a single line of code.

You will need Node.js 22 or higher. Node 22 ships with native .env file support and improved ESM handling, both of which Vite 8 uses. Check your version with node -v. If you are on an older version, use nvm or Volta to install the latest LTS release without disrupting existing projects.

For the package manager, this tutorial uses pnpm 9+, which is faster and more disk-efficient than npm or Yarn for monorepo-style projects. Install it globally with npm install -g pnpm. All commands will also work with npm if you prefer – simply replace pnpm with npm throughout.

Your code editor should be VS Code with the Vue – Official extension (formerly Volar), which provides TypeScript-aware template type checking, auto-imports, and Go-to-Definition support for Vue single-file components. Avoid using the legacy Vetur extension – it does not support Vue 3’s Composition API fully and will produce misleading errors.

ToolMinimum VersionRecommended VersionPurpose
Node.js20.x LTS22.x LTSJavaScript runtime
pnpm8.x9.xPackage management
Vue.js3.4.x3.5.31UI framework
Vite6.x8.0Build tool & dev server
Pinia2.1.x2.3.xState management
Vue Router4.3.x4.5.xClient-side routing
TypeScript5.3.x5.7.xStatic typing
Tailwind CSS3.4.x4.1.xUtility-first CSS
Vitest3.x4.1Unit testing
VS Code ExtensionVue – Official 2.xVue – Official 2.xIDE support

With your environment verified, let’s build something substantial. The Task Manager application you will create supports creating, editing, completing, and deleting tasks; filtering by status and priority; full-text search; and persistent storage via localStorage. It is small enough to complete in a single session but complex enough to demonstrate every major Vue 3 pattern.

Step 1: Creating the Vue.js 3 Project with Vite

Vite 8, released in March 2026, introduced the Rolldown bundler as its default production bundler – a Rust-based alternative to Rollup that delivers 5–10x faster builds on large codebases. The developer experience remains identical: Vite 8 is a drop-in upgrade for most projects. This vue.js 3 tutorial uses Vite 8 from the start so you benefit from these improvements immediately.

Scaffold the project using create-vue, the official scaffolding tool maintained by the Vue core team. It generates an opinionated but minimal project that wires together Vite, TypeScript, Vue Router, Pinia, and Vitest with correct configurations out of the box.

pnpm create vue@latest task-manager-vue

# When prompted, select the following options:
# βœ” Add TypeScript? β€Ί Yes
# βœ” Add JSX Support? β€Ί No
# βœ” Add Vue Router for Single Page Application development? β€Ί Yes
# βœ” Add Pinia for state management? β€Ί Yes
# βœ” Add Vitest for Unit Testing? β€Ί Yes
# βœ” Add an End-to-End Testing Solution? β€Ί No
# βœ” Add ESLint for code quality? β€Ί Yes
# βœ” Add Prettier for code formatting? β€Ί Yes
# βœ” Add Vue DevTools 7 extension for debugging? β€Ί Yes

cd task-manager-vue
pnpm install

After installation, verify the dev server starts correctly before modifying anything. This confirms your Node version and pnpm setup are working as expected.

pnpm dev

# Expected output:
# VITE v8.0.x ready in 312 ms
# ➜ Local: http://localhost:5173/
# ➜ Network: use --host to expose
# ➜ press h + enter to show help

Open http://localhost:5173 in your browser. You should see the default Vue welcome page. Now install Tailwind CSS and its Vite plugin before touching any source files – getting the CSS pipeline in place first prevents layout debugging frustration later.

# Install Tailwind CSS v4 with the Vite plugin
pnpm add -D tailwindcss @tailwindcss/vite

# For Tailwind v4, no separate postcss.config is needed
# The Vite plugin handles everything

Tailwind CSS 4.1 uses a new CSS-first configuration model – no more tailwind.config.js for most projects. Configuration lives directly in your CSS file using @theme directives, which dramatically simplifies the setup. This is a significant departure from v3 and it is important to use the correct documentation for the version you are installing. See the official Tailwind CSS docs for migration notes if you are coming from v3.

Step 2: Project Structure and Configuration

A well-organized project structure prevents the β€œwhere does this file go?” decision fatigue that slows teams down as codebases grow. The structure below reflects current Vue community conventions for medium-to-large applications. Understanding it upfront makes the subsequent steps much easier to follow.

task-manager-vue/
β”œβ”€β”€ src/
β”‚ β”œβ”€β”€ assets/
β”‚ β”‚ └── main.css # Global styles + Tailwind import
β”‚ β”œβ”€β”€ components/
β”‚ β”‚ β”œβ”€β”€ TaskList.vue # Displays all tasks
β”‚ β”‚ β”œβ”€β”€ TaskItem.vue # Individual task card
β”‚ β”‚ β”œβ”€β”€ AddTaskForm.vue # Form for creating tasks
β”‚ β”‚ β”œβ”€β”€ TaskFilter.vue # Filter controls
β”‚ β”‚ └── SearchBar.vue # Search input
β”‚ β”œβ”€β”€ composables/
β”‚ β”‚ β”œβ”€β”€ useTaskFilters.ts # Filter/search logic
β”‚ β”‚ └── useLocalStorage.ts # Persistence helper
β”‚ β”œβ”€β”€ router/
β”‚ β”‚ └── index.ts # Route definitions
β”‚ β”œβ”€β”€ stores/
β”‚ β”‚ └── taskStore.ts # Pinia task store
β”‚ β”œβ”€β”€ types/
β”‚ β”‚ └── task.ts # TypeScript interfaces
β”‚ β”œβ”€β”€ views/
β”‚ β”‚ β”œβ”€β”€ HomeView.vue # Main task view
β”‚ β”‚ β”œβ”€β”€ CompletedView.vue # Completed tasks
β”‚ β”‚ └── AboutView.vue # About page
β”‚ β”œβ”€β”€ App.vue # Root component
β”‚ └── main.ts # Application entry point
β”œβ”€β”€ tests/
β”‚ └── unit/
β”‚ β”œβ”€β”€ taskStore.spec.ts
β”‚ └── useTaskFilters.spec.ts
β”œβ”€β”€ vite.config.ts
β”œβ”€β”€ tsconfig.json
└── package.json

Update vite.config.ts to include the Tailwind plugin and configure path aliases, which make imports cleaner throughout the codebase. Path aliases eliminate the fragile relative import chains (../../../components/TaskList) that are hard to refactor later.

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

export default defineConfig({
 plugins: [
 vue(),
 tailwindcss(),
 ],
 resolve: {
 alias: {
 '@': fileURLToPath(new URL('./src', import.meta.url)),
 },
 },
 test: {
 environment: 'jsdom',
 globals: true,
 setupFiles: ['./src/test-setup.ts'],
 },
})

The @ alias maps to the src directory, so import TaskList from '@/components/TaskList.vue' works from anywhere in the project. TypeScript needs to know about this alias too – update tsconfig.json to include the path mapping under compilerOptions.paths. The create-vue scaffolder usually handles this automatically, but verify it is present.

Step 3: Setting Up TypeScript and Tailwind CSS

TypeScript and Vue 3 are a genuinely excellent combination in 2026. The Vue – Official VS Code extension provides template-level type checking – meaning typos in prop names, missing required props, and incorrect event payloads are caught at edit time rather than runtime. This is a qualitative improvement over the Vue 2 era and a key reason experienced teams default to TypeScript for new Vue projects.

Start by defining your TypeScript interfaces. Centralizing type definitions in a types/ directory makes them easy to import and update when requirements change. For more on the TypeScript vs JavaScript tradeoffs, see our TypeScript vs JavaScript 2026 comparison.

// src/types/task.ts
export type TaskPriority = 'low' | 'medium' | 'high'
export type TaskStatus = 'active' | 'completed'

export interface Task {
 id: string
 title: string
 description: string
 priority: TaskPriority
 status: TaskStatus
 createdAt: string
 completedAt: string | null
 tags: string[]
}

export interface TaskFormData {
 title: string
 description: string
 priority: TaskPriority
 tags: string[]
}

export type TaskFilterOption = 'all' | TaskStatus
export type TaskSortOption = 'createdAt' | 'priority' | 'title'

For Tailwind CSS v4, the configuration lives in your main CSS file. Replace the contents of src/assets/main.css with the following:

/* src/assets/main.css */
@import "tailwindcss";

@theme {
 --color-primary-50: #eff6ff;
 --color-primary-100: #dbeafe;
 --color-primary-500: #3b82f6;
 --color-primary-600: #2563eb;
 --color-primary-700: #1d4ed8;
 --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
}

/* Base layer overrides */
@layer base {
 body {
 @apply bg-gray-50 text-gray-900 antialiased;
 }

 h1, h2, h3, h4 {
 @apply font-semibold tracking-tight;
 }
}

/* Custom component classes */
@layer components {
 .btn-primary {
 @apply inline-flex items-center gap-2 rounded-lg bg-primary-600 px-4 py-2
 text-sm font-medium text-white shadow-sm transition-colors
 hover:bg-primary-700 focus:outline-none focus:ring-2
 focus:ring-primary-500 focus:ring-offset-2 disabled:opacity-50;
 }

 .btn-secondary {
 @apply inline-flex items-center gap-2 rounded-lg border border-gray-300
 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm
 transition-colors hover:bg-gray-50 focus:outline-none
 focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
 }

 .input-field {
 @apply w-full rounded-lg border border-gray-300 bg-white px-3 py-2
 text-sm placeholder-gray-400 shadow-sm transition-colors
 focus:border-primary-500 focus:outline-none focus:ring-1
 focus:ring-primary-500;
 }

 .card {
 @apply rounded-xl border border-gray-200 bg-white p-4 shadow-sm;
 }
}

The @theme directive in Tailwind v4 replaces the theme.extend object from tailwind.config.js. Custom CSS variables defined inside @theme are automatically available as Tailwind utility classes – so --color-primary-600 becomes bg-primary-600, text-primary-600, and so on without any additional configuration. This is a substantial developer experience improvement over v3.

Step 4: Configuring Vue Router for Navigation

Vue Router 4.5 ships with improved type safety for route params and query strings, and full support for the Composition API’s useRoute and useRouter composables. The router configuration uses the createWebHistory mode, which produces clean URLs without hash fragments – important for SEO and bookmarkability even in SPA applications.

// 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),
 scrollBehavior: () => ({ top: 0 }),
 routes: [
 {
 path: '/',
 name: 'home',
 component: HomeView,
 meta: { title: 'All Tasks' },
 },
 {
 path: '/completed',
 name: 'completed',
 component: () => import('@/views/CompletedView.vue'),
 meta: { title: 'Completed Tasks' },
 },
 {
 path: '/about',
 name: 'about',
 component: () => import('@/views/AboutView.vue'),
 meta: { title: 'About' },
 },
 ],
})

// Update document title on navigation
router.afterEach((to) => {
 const title = to.meta.title as string | undefined
 document.title = title ? `${title} β€” Task Manager` : 'Task Manager'
})

export default router

Notice that CompletedView and AboutView use dynamic imports (() => import(...)). This enables route-level code splitting – Vite will create separate bundles for each lazy-loaded route, meaning users only download code for pages they actually visit. For this small application the performance difference is negligible, but the pattern is important to establish early because it matters significantly in larger applications.

Update App.vue to include navigation and the router outlet:

<!-- src/App.vue -->
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import { useTaskStore } from '@/stores/taskStore'

const taskStore = useTaskStore()
</script>

<template>
 <div class="min-h-screen bg-gray-50">
 <!-- Navigation -->
 <nav class="border-b border-gray-200 bg-white shadow-sm">
 <div class="mx-auto max-w-4xl px-4 sm:px-6">
 <div class="flex h-14 items-center justify-between">
 <span class="text-base font-semibold text-primary-600">
 TaskManager
 </span>
 <div class="flex items-center gap-6 text-sm">
 <RouterLink
 to="/"
 class="font-medium text-gray-600 transition-colors hover:text-primary-600"
 active-class="text-primary-600"
 >
 All Tasks
 <span class="ml-1 rounded-full bg-primary-100 px-2 py-0.5 text-xs text-primary-700">
 {{ taskStore.activeTasks.length }}
 </span>
 </RouterLink>
 <RouterLink
 to="/completed"
 class="font-medium text-gray-600 transition-colors hover:text-primary-600"
 active-class="text-primary-600"
 >
 Completed
 </RouterLink>
 </div>
 </div>
 </div>
 </nav>

 <!-- Main content -->
 <main class="mx-auto max-w-4xl px-4 py-8 sm:px-6">
 <RouterView />
 </main>
 </div>
</template>

Step 5: Building the Pinia Store for State Management

Pinia is the officially recommended state management library for Vue 3, and it has matured significantly since replacing Vuex as the default recommendation. Its setup store syntax mirrors the Composition API closely – if you are comfortable with ref, computed, and functions, you already know 80% of Pinia. This is the section where pinia vue 3 integration truly shines.

It is also worth noting that Pinia Colada – the async data fetching companion library for Pinia – reached its first stable release in early 2026. Pinia Colada handles server-state concerns (caching, deduplication, background revalidation) that Pinia itself intentionally leaves out of scope. For applications that fetch data from APIs, Pinia Colada is now the recommended complement. This tutorial focuses on client-side state, but the pattern translates directly.

The task store uses the setup store syntax, which is more flexible than the options store syntax and works smoothly with TypeScript generics.

// src/stores/taskStore.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { nanoid } from 'nanoid'
import type { Task, TaskFormData, TaskFilterOption, TaskSortOption } from '@/types/task'

const STORAGE_KEY = 'task-manager-tasks'

function loadFromStorage(): Task[] {
 try {
 const raw = localStorage.getItem(STORAGE_KEY)
 return raw ? JSON.parse(raw) : []
 } catch {
 return []
 }
}

function saveToStorage(tasks: Task[]): void {
 localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks))
}

export const useTaskStore = defineStore('tasks', () => {
 // State
 const tasks = ref<Task[]>(loadFromStorage())
 const filterStatus = ref<TaskFilterOption>('all')
 const sortBy = ref<TaskSortOption>('createdAt')
 const searchQuery = ref('')

 // Computed: derived task lists
 const activeTasks = computed(() =>
 tasks.value.filter((t) => t.status === 'active')
 )

 const completedTasks = computed(() =>
 tasks.value.filter((t) => t.status === 'completed')
 )

 const filteredTasks = computed(() => {
 let result = tasks.value

 // Apply status filter
 if (filterStatus.value !== 'all') {
 result = result.filter((t) => t.status === filterStatus.value)
 }

 // Apply search query
 if (searchQuery.value.trim()) {
 const query = searchQuery.value.toLowerCase()
 result = result.filter(
 (t) =>
 t.title.toLowerCase().includes(query) ||
 t.description.toLowerCase().includes(query) ||
 t.tags.some((tag) => tag.toLowerCase().includes(query))
 )
 }

 // Apply sorting
 return [...result].sort((a, b) => {
 if (sortBy.value === 'priority') {
 const order = { high: 0, medium: 1, low: 2 }
 return order[a.priority] - order[b.priority]
 }
 if (sortBy.value === 'title') {
 return a.title.localeCompare(b.title)
 }
 // Default: sort by createdAt descending
 return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
 })
 })

 // Actions
 function addTask(formData: TaskFormData): Task {
 const newTask: Task = {
 id: nanoid(),
 ...formData,
 status: 'active',
 createdAt: new Date().toISOString(),
 completedAt: null,
 }
 tasks.value.unshift(newTask)
 persistTasks()
 return newTask
 }

 function completeTask(id: string): void {
 const task = tasks.value.find((t) => t.id === id)
 if (task) {
 task.status = 'completed'
 task.completedAt = new Date().toISOString()
 persistTasks()
 }
 }

 function reopenTask(id: string): void {
 const task = tasks.value.find((t) => t.id === id)
 if (task) {
 task.status = 'active'
 task.completedAt = null
 persistTasks()
 }
 }

 function deleteTask(id: string): void {
 const index = tasks.value.findIndex((t) => t.id === id)
 if (index !== -1) {
 tasks.value.splice(index, 1)
 persistTasks()
 }
 }

 function updateTask(id: string, updates: Partial<TaskFormData>): void {
 const task = tasks.value.find((t) => t.id === id)
 if (task) {
 Object.assign(task, updates)
 persistTasks()
 }
 }

 function clearCompleted(): void {
 tasks.value = tasks.value.filter((t) => t.status === 'active')
 persistTasks()
 }

 function persistTasks(): void {
 saveToStorage(tasks.value)
 }

 return {
 // State
 tasks,
 filterStatus,
 sortBy,
 searchQuery,
 // Computed
 activeTasks,
 completedTasks,
 filteredTasks,
 // Actions
 addTask,
 completeTask,
 reopenTask,
 deleteTask,
 updateTask,
 clearCompleted,
 }
})

Install nanoid for generating unique task IDs – it produces URL-safe, compact IDs without the collision risk of Math.random(): pnpm add nanoid.

Several design decisions in this store deserve explanation. The loadFromStorage and saveToStorage functions are intentionally plain functions rather than composables because they are synchronous and called only from within the store. The persistTasks action is called at the end of every mutating action – a deliberate choice over a watch on tasks that would fire on every nested mutation including computed recomputes. This avoids unnecessary serialize/write cycles and keeps persistence explicit and traceable.

Step 6: Creating the Task List Component

The TaskList component is the heart of the UI. It reads from the Pinia store, renders filtered and sorted tasks, and delegates individual task actions to a child TaskItem component. This separation keeps each component focused on a single responsibility and makes the codebase easier to test and reason about independently.

<!-- src/components/TaskList.vue -->
<script setup lang="ts">
import { useTaskStore } from '@/stores/taskStore'
import TaskItem from './TaskItem.vue'

const taskStore = useTaskStore()
</script>

<template>
 <div>
 <!-- Empty state -->
 <div
 v-if="taskStore.filteredTasks.length === 0"
 class="rounded-xl border-2 border-dashed border-gray-200 py-16 text-center"
 >
 <p class="text-sm font-medium text-gray-500">
 {{
 taskStore.searchQuery
 ? 'No tasks match your search.'
 : 'No tasks yet. Add one above!'
 }}
 </p>
 </div>

 <!-- Task items with animated transitions -->
 <TransitionGroup
 v-else
 name="task-list"
 tag="ul"
 class="space-y-3"
 >
 <TaskItem
 v-for="task in taskStore.filteredTasks"
 :key="task.id"
 :task="task"
 @complete="taskStore.completeTask(task.id)"
 @reopen="taskStore.reopenTask(task.id)"
 @delete="taskStore.deleteTask(task.id)"
 />
 </TransitionGroup>

 <!-- Bulk action: clear completed -->
 <div
 v-if="taskStore.completedTasks.length > 0"
 class="mt-6 flex justify-end"
 >
 <button
 class="btn-secondary text-red-600 hover:bg-red-50 hover:border-red-300"
 @click="taskStore.clearCompleted()"
 >
 Clear {{ taskStore.completedTasks.length }} completed
 </button>
 </div>
 </div>
</template>

<style scoped>
.task-list-enter-active,
.task-list-leave-active {
 transition: all 0.25s ease;
}

.task-list-enter-from,
.task-list-leave-to {
 opacity: 0;
 transform: translateX(-12px);
}

.task-list-move {
 transition: transform 0.25s ease;
}
</style>

Vue’s built-in <TransitionGroup> component animates list insertions, removals, and reorders with pure CSS. The .task-list-move class handles the FLIP animation when items reorder – Vue calculates the start and end positions and applies CSS transforms to create smooth movement without any JavaScript animation logic on your part. This is one of the most elegant built-in features in the Vue ecosystem.

TaskItem Component

The TaskItem component receives a single task prop and emits typed events using the object form of defineEmits, which provides superior IDE autocomplete and catches event name typos at compile time.

<!-- src/components/TaskItem.vue -->
<script setup lang="ts">
import type { Task } from '@/types/task'

defineProps<{ task: Task }>()

const emit = defineEmits<{
 complete: []
 reopen: []
 delete: []
}>()

const priorityClasses: Record<Task['priority'], string> = {
 high: 'bg-red-100 text-red-700',
 medium: 'bg-yellow-100 text-yellow-700',
 low: 'bg-green-100 text-green-700',
}

function formatDate(iso: string): string {
 return new Intl.DateTimeFormat('en-US', {
 month: 'short',
 day: 'numeric',
 }).format(new Date(iso))
}
</script>

<template>
 <li
 class="card flex items-start gap-3 transition-opacity"
 :class="{ 'opacity-60': task.status === 'completed' }"
 >
 <!-- Checkbox toggle -->
 <button
 class="mt-0.5 h-5 w-5 flex-shrink-0 rounded border-2 transition-colors
 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
 :class="
 task.status === 'completed'
 ? 'border-primary-500 bg-primary-500'
 : 'border-gray-300 hover:border-primary-400'
 "
 :aria-label="task.status === 'completed' ? 'Reopen task' : 'Complete task'"
 @click="task.status === 'completed' ? emit('reopen') : emit('complete')"
 >
 <svg
 v-if="task.status === 'completed'"
 class="h-full w-full text-white"
 viewBox="0 0 20 20"
 fill="currentColor"
 aria-hidden="true"
 >
 <path
 fill-rule="evenodd"
 d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0
 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
 clip-rule="evenodd"
 />
 </svg>
 </button>

 <!-- Task content -->
 <div class="min-w-0 flex-1">
 <p
 class="text-sm font-medium text-gray-900"
 :class="{ 'line-through text-gray-400': task.status === 'completed' }"
 >
 {{ task.title }}
 </p>
 <p v-if="task.description" class="mt-0.5 text-xs text-gray-500">
 {{ task.description }}
 </p>
 <div class="mt-2 flex flex-wrap items-center gap-2">
 <span
 class="rounded-full px-2 py-0.5 text-xs font-medium"
 :class="priorityClasses[task.priority]"
 >
 {{ task.priority }}
 </span>
 <span
 v-for="tag in task.tags"
 :key="tag"
 class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600"
 >
 #{{ tag }}
 </span>
 <span class="text-xs text-gray-400">{{ formatDate(task.createdAt) }}</span>
 </div>
 </div>

 <!-- Delete button -->
 <button
 class="flex-shrink-0 rounded p-1 text-gray-400 transition-colors
 hover:bg-red-50 hover:text-red-500 focus:outline-none
 focus:ring-2 focus:ring-red-400"
 aria-label="Delete task"
 @click="emit('delete')"
 >
 <svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
 <path
 fill-rule="evenodd"
 d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2
 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1
 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0
 00-1-1z"
 clip-rule="evenodd"
 />
 </svg>
 </button>
 </li>
</template>

Step 7: Building the Add Task Form Component

The add task form demonstrates several important Vue 3 patterns: reactive form state with reactive(), inline validation without a third-party library, conditional rendering of expanded fields using <Transition>, and tag input with keyboard event handling. Keeping form state local to the component – rather than in Pinia – is the correct architectural choice here. The data only needs to be stored globally once the user submits, not while they are actively typing.

<!-- src/components/AddTaskForm.vue -->
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useTaskStore } from '@/stores/taskStore'
import type { TaskFormData } from '@/types/task'

const taskStore = useTaskStore()

const isExpanded = ref(false)
const tagInput = ref('')

const form = reactive<TaskFormData>({
 title: '',
 description: '',
 priority: 'medium',
 tags: [],
})

const errors = reactive({ title: '' })

function validateForm(): boolean {
 errors.title = ''
 if (!form.title.trim()) {
 errors.title = 'Task title is required.'
 return false
 }
 if (form.title.length > 120) {
 errors.title = 'Title must be 120 characters or fewer.'
 return false
 }
 return true
}

function addTag(): void {
 const tag = tagInput.value.trim().toLowerCase().replace(/s+/g, '-')
 if (tag && !form.tags.includes(tag) && form.tags.length < 5) {
 form.tags.push(tag)
 tagInput.value = ''
 }
}

function removeTag(tag: string): void {
 const index = form.tags.indexOf(tag)
 if (index !== -1) form.tags.splice(index, 1)
}

function handleTagKeydown(event: KeyboardEvent): void {
 if (event.key === 'Enter' || event.key === ',') {
 event.preventDefault()
 addTag()
 }
}

function submitForm(): void {
 if (!validateForm()) return
 taskStore.addTask({ ...form, tags: [...form.tags] })
 // Reset form state
 form.title = ''
 form.description = ''
 form.priority = 'medium'
 form.tags = []
 tagInput.value = ''
 isExpanded.value = false
}
</script>

<template>
 <div class="card mb-6">
 <form @submit.prevent="submitForm">
 <!-- Title input row (always visible) -->
 <div class="flex gap-3">
 <div class="flex-1">
 <input
 v-model="form.title"
 type="text"
 placeholder="Add a new task…"
 class="input-field"
 :class="{ 'border-red-400 focus:border-red-400 focus:ring-red-400': errors.title }"
 @focus="isExpanded = true"
 />
 <p v-if="errors.title" class="mt-1 text-xs text-red-600">{{ errors.title }}</p>
 </div>
 <button type="submit" class="btn-primary flex-shrink-0">Add Task</button>
 </div>

 <!-- Expanded fields (description, priority, tags) -->
 <Transition name="expand">
 <div v-if="isExpanded" class="mt-4 space-y-3">
 <textarea
 v-model="form.description"
 rows="2"
 placeholder="Description (optional)"
 class="input-field resize-none"
 />

 <div class="flex flex-wrap gap-3">
 <!-- Priority selector -->
 <div>
 <label class="mb-1 block text-xs font-medium text-gray-600">Priority</label>
 <select v-model="form.priority" class="input-field w-auto">
 <option value="low">Low</option>
 <option value="medium">Medium</option>
 <option value="high">High</option>
 </select>
 </div>

 <!-- Tag input with badge display -->
 <div class="flex-1">
 <label class="mb-1 block text-xs font-medium text-gray-600">
 Tags <span class="text-gray-400">(press Enter or comma to add, max 5)</span>
 </label>
 <div
 class="flex flex-wrap items-center gap-1.5 rounded-lg border border-gray-300
 bg-white px-2 py-1.5 focus-within:border-primary-500
 focus-within:ring-1 focus-within:ring-primary-500"
 >
 <span
 v-for="tag in form.tags"
 :key="tag"
 class="flex items-center gap-1 rounded-full bg-primary-100 px-2
 py-0.5 text-xs font-medium text-primary-700"
 >
 #{{ tag }}
 <button
 type="button"
 class="text-primary-500 hover:text-primary-700 focus:outline-none"
 :aria-label="`Remove tag ${tag}`"
 @click="removeTag(tag)"
 >
 &times;
 </button>
 </span>
 <input
 v-model="tagInput"
 type="text"
 class="min-w-[80px] flex-1 border-0 bg-transparent p-0 text-sm
 outline-none placeholder-gray-400"
 placeholder="Add tag…"
 :disabled="form.tags.length >= 5"
 @keydown="handleTagKeydown"
 @blur="addTag"
 />
 </div>
 </div>
 </div>
 </div>
 </Transition>
 </form>
 </div>
</template>

<style scoped>
.expand-enter-active,
.expand-leave-active {
 transition: all 0.2s ease;
 overflow: hidden;
}

.expand-enter-from,
.expand-leave-to {
 opacity: 0;
 max-height: 0;
}

.expand-enter-to,
.expand-leave-from {
 max-height: 300px;
}
</style>

This form uses reactive for the form object and ref for simple boolean and string values. This is a common and idiomatic pattern: reactive works well for objects with multiple related fields because you can mutate them directly without the .value accessor, while ref is preferred for primitives and single values. Neither choice is wrong – choose whatever feels most readable and be consistent within a project.

Step 8: Implementing Task Filtering and Search

The filtering and search functionality is already implemented in the Pinia store’s computed properties, but the UI components that control those store values are equally important. This step builds the TaskFilter component and introduces the composable pattern for encapsulating related reactive logic that might be needed in multiple locations.

Composables are a defining feature of Vue 3’s Composition API – they are ordinary functions that use Vue reactivity primitives and can be shared across components without the drawbacks of mixins (name conflicts, implicit dependencies, unclear provenance of properties). The useTaskFilters composable centralizes filter UI logic so it does not need to be duplicated if filter controls appear in multiple views.

// src/composables/useTaskFilters.ts
import { computed } from 'vue'
import { useTaskStore } from '@/stores/taskStore'
import type { TaskFilterOption, TaskSortOption } from '@/types/task'

export function useTaskFilters() {
 const taskStore = useTaskStore()

 const filterOptions: { value: TaskFilterOption; label: string }[] = [
 { value: 'all', label: 'All' },
 { value: 'active', label: 'Active' },
 { value: 'completed', label: 'Completed' },
 ]

 const sortOptions: { value: TaskSortOption; label: string }[] = [
 { value: 'createdAt', label: 'Newest first' },
 { value: 'priority', label: 'Priority' },
 { value: 'title', label: 'Alphabetical' },
 ]

 // Writable computed for v-model binding without exposing store internals
 const searchQuery = computed({
 get: () => taskStore.searchQuery,
 set: (val: string) => { taskStore.searchQuery = val },
 })

 function setFilter(value: TaskFilterOption) {
 taskStore.filterStatus = value
 }

 function setSort(value: TaskSortOption) {
 taskStore.sortBy = value
 }

 function clearSearch() {
 taskStore.searchQuery = ''
 }

 return {
 filterOptions,
 sortOptions,
 searchQuery,
 currentFilter: computed(() => taskStore.filterStatus),
 currentSort: computed(() => taskStore.sortBy),
 setFilter,
 setSort,
 clearSearch,
 }
}

The writable computed for searchQuery – using the { get, set } syntax – enables two-way binding with v-model in components without exposing Pinia store internals directly. This is an important architectural boundary: components should not directly mutate Pinia state through storeToRefs destructuring on writable refs; instead, use computed setters and actions to provide a controlled, traceable interface.

<!-- src/components/TaskFilter.vue -->
<script setup lang="ts">
import { useTaskFilters } from '@/composables/useTaskFilters'

const { filterOptions, sortOptions, currentFilter, currentSort, setFilter, setSort } =
 useTaskFilters()
</script>

<template>
 <div class="mb-4 flex flex-wrap items-center justify-between gap-3">
 <!-- Filter tab group -->
 <div class="flex rounded-lg border border-gray-200 bg-white p-1 shadow-sm">
 <button
 v-for="option in filterOptions"
 :key="option.value"
 class="rounded-md px-3 py-1.5 text-sm font-medium transition-all"
 :class="
 currentFilter === option.value
 ? 'bg-primary-600 text-white shadow-sm'
 : 'text-gray-600 hover:bg-gray-100'
 "
 @click="setFilter(option.value)"
 >
 {{ option.label }}
 </button>
 </div>

 <!-- Sort dropdown -->
 <div class="flex items-center gap-2">
 <label class="text-xs font-medium text-gray-500">Sort:</label>
 <select
 :value="currentSort"
 class="input-field w-auto py-1.5 text-sm"
 @change="setSort(($event.target as HTMLSelectElement).value as TaskSortOption)"
 >
 <option
 v-for="opt in sortOptions"
 :key="opt.value"
 :value="opt.value"
 >
 {{ opt.label }}
 </option>
 </select>
 </div>
 </div>
</template>

Step 9: Adding Local Storage Persistence

Persistence is already wired into the Pinia store via the persistTasks function called after every mutation. This step builds a reusable useLocalStorage composable that can be used outside the store context, and explains the tradeoffs between manual persistence and the pinia-plugin-persistedstate plugin for teams building more complex applications.

// src/composables/useLocalStorage.ts
import { ref, watch } from 'vue'
import type { Ref } from 'vue'

export function useLocalStorage<T>(key: string, defaultValue: T): Ref<T> {
 // Guard for SSR environments (Nuxt, etc.)
 const isClient = typeof window !== 'undefined'

 let initialValue: T = defaultValue
 if (isClient) {
 const storedValue = localStorage.getItem(key)
 try {
 initialValue = storedValue !== null ? JSON.parse(storedValue) : defaultValue
 } catch {
 initialValue = defaultValue
 }
 }

 const state = ref<T>(initialValue) as Ref<T>

 if (isClient) {
 watch(
 state,
 (newValue) => {
 try {
 localStorage.setItem(key, JSON.stringify(newValue))
 } catch (error) {
 // Handle storage quota exceeded or private browsing restrictions
 console.warn(`Failed to persist "${key}" to localStorage:`, error)
 }
 },
 { deep: true }
 )
 }

 return state
}

This generic composable accepts a storage key and a default value, reads the initial value from localStorage with JSON parsing, and sets up a deep watcher to persist changes automatically. The TypeScript generic <T> ensures the returned ref is correctly typed – passing a Task[] default value gives back a Ref<Task[]> without any manual type assertions. The SSR guard makes this composable safe to use in Nuxt applications.

Persistence ApproachSetup ComplexitySSR SafePartial StateBest For
Manual (store actions)LowRequires guardFull controlLearning, simple apps
pinia-plugin-persistedstateVery lowYesYes (pick/omit)Most production apps
useLocalStorage composableLowWith guardN/A (per-ref)Component-level persistence
IndexedDB (idb-keyval)MediumNoFull controlLarge data sets (>5MB)
Custom Pinia pluginHighConfigurableUnlimitedEnterprise / complex apps

For production applications, pinia-plugin-persistedstate is the recommended approach over manual persistence. It handles serialization edge cases, storage key namespacing, selective field exclusion, and SSR-safe storage access out of the box. The manual approach used in this tutorial is valuable for understanding how persistence works under the hood – knowledge that makes the plugin’s configuration options much more intuitive.

The completed HomeView wires all components together into a cohesive page:

<!-- src/views/HomeView.vue -->
<script setup lang="ts">
import AddTaskForm from '@/components/AddTaskForm.vue'
import TaskFilter from '@/components/TaskFilter.vue'
import TaskList from '@/components/TaskList.vue'
import { useTaskFilters } from '@/composables/useTaskFilters'

const { searchQuery, clearSearch } = useTaskFilters()
</script>

<template>
 <div>
 <div class="mb-6">
 <h1 class="text-2xl font-bold text-gray-900">My Tasks</h1>
 <p class="mt-1 text-sm text-gray-500">Organize your work, stay focused.</p>
 </div>

 <AddTaskForm />

 <!-- Search bar -->
 <div class="relative mb-4">
 <input
 v-model="searchQuery"
 type="search"
 placeholder="Search tasks by title, description, or tag…"
 class="input-field pl-9"
 />
 <svg
 class="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-gray-400"
 fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
 aria-hidden="true"
 >
 <path stroke-linecap="round" stroke-linejoin="round"
 d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
 </svg>
 <button
 v-if="searchQuery"
 class="absolute right-2.5 top-2 rounded p-0.5 text-gray-400 hover:text-gray-600"
 aria-label="Clear search"
 @click="clearSearch"
 >
 &times;
 </button>
 </div>

 <TaskFilter />
 <TaskList />
 </div>
</template>

Step 10: Testing with Vitest

Vitest 4.1, released with Vite 8 support, brings improved test isolation via per-test module instance resetting, a faster watch mode powered by Rolldown, and a production-ready browser mode using Playwright or WebdriverIO. For this vue.js project tutorial, the focus is on unit tests for the Pinia store and composables using jsdom. See the official Vitest documentation for advanced configuration including browser mode and component testing with Vue Test Utils.

// src/test-setup.ts
import { beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'

// Create a fresh Pinia instance before each test to prevent state leakage
beforeEach(() => {
 setActivePinia(createPinia())
})

// In-memory localStorage stub for test environment
const localStorageMock = (() => {
 let store: Record<string, string> = {}
 return {
 getItem: (key: string) => store[key] ?? null,
 setItem: (key: string, value: string) => { store[key] = value },
 removeItem: (key: string) => { delete store[key] },
 clear: () => { store = {} },
 }
})()

Object.defineProperty(globalThis, 'localStorage', {
 value: localStorageMock,
 writable: true,
})
// tests/unit/taskStore.spec.ts
import { describe, it, expect } from 'vitest'
import { useTaskStore } from '@/stores/taskStore'

const sampleTask = {
 title: 'Write unit tests',
 description: 'Cover all store actions',
 priority: 'high' as const,
 tags: ['testing', 'vue'],
}

describe('taskStore', () => {
 it('initializes with an empty task list in test environment', () => {
 const store = useTaskStore()
 expect(store.tasks).toBeInstanceOf(Array)
 })

 it('adds a new task with correct default values', () => {
 const store = useTaskStore()
 const task = store.addTask(sampleTask)

 expect(store.tasks).toHaveLength(1)
 expect(task.id).toBeTruthy()
 expect(task.status).toBe('active')
 expect(task.completedAt).toBeNull()
 expect(task.tags).toContain('testing')
 expect(task.createdAt).toBeTruthy()
 })

 it('completes a task and records completedAt timestamp', () => {
 const store = useTaskStore()
 const task = store.addTask(sampleTask)

 store.completeTask(task.id)

 const updated = store.tasks.find((t) => t.id === task.id)
 expect(updated?.status).toBe('completed')
 expect(updated?.completedAt).not.toBeNull()
 })

 it('reopens a completed task', () => {
 const store = useTaskStore()
 const task = store.addTask(sampleTask)
 store.completeTask(task.id)
 store.reopenTask(task.id)

 const updated = store.tasks.find((t) => t.id === task.id)
 expect(updated?.status).toBe('active')
 expect(updated?.completedAt).toBeNull()
 })

 it('deletes a task by id', () => {
 const store = useTaskStore()
 const task = store.addTask(sampleTask)
 store.deleteTask(task.id)

 expect(store.tasks).toHaveLength(0)
 })

 it('filters tasks by status via filteredTasks computed', () => {
 const store = useTaskStore()
 store.addTask({ title: 'Active', description: '', priority: 'low', tags: [] })
 const t2 = store.addTask({ title: 'Done', description: '', priority: 'low', tags: [] })
 store.completeTask(t2.id)

 store.filterStatus = 'active'
 expect(store.filteredTasks.every((t) => t.status === 'active')).toBe(true)

 store.filterStatus = 'completed'
 expect(store.filteredTasks.every((t) => t.status === 'completed')).toBe(true)
 })

 it('searches tasks by title', () => {
 const store = useTaskStore()
 store.addTask({ title: 'Buy groceries', description: '', priority: 'low', tags: [] })
 store.addTask({ title: 'Write report', description: '', priority: 'medium', tags: [] })

 store.searchQuery = 'groceries'
 expect(store.filteredTasks).toHaveLength(1)
 expect(store.filteredTasks[0].title).toBe('Buy groceries')
 })

 it('searches tasks by tag', () => {
 const store = useTaskStore()
 store.addTask({ title: 'Task A', description: '', priority: 'low', tags: ['urgent'] })
 store.addTask({ title: 'Task B', description: '', priority: 'low', tags: ['backlog'] })

 store.searchQuery = 'urgent'
 expect(store.filteredTasks).toHaveLength(1)
 })

 it('clearCompleted removes only completed tasks', () => {
 const store = useTaskStore()
 const t1 = store.addTask({ title: 'Active', description: '', priority: 'medium', tags: [] })
 const t2 = store.addTask({ title: 'Done', description: '', priority: 'low', tags: [] })
 store.completeTask(t2.id)

 store.clearCompleted()

 expect(store.tasks).toHaveLength(1)
 expect(store.tasks[0].id).toBe(t1.id)
 })
})

Run the full test suite with pnpm test. Vitest watches for file changes and re-runs only the affected tests – the feedback loop is nearly instantaneous for unit tests. Generate an HTML coverage report with pnpm test --coverage after installing @vitest/coverage-v8: pnpm add -D @vitest/coverage-v8. Aim for 80%+ coverage on store files and composables – they contain business logic that is straightforward to test and expensive to debug in production.

Common Pitfalls and How to Avoid Them

Even experienced developers encounter these issues when first working with Vue 3 and Pinia. Understanding them up front – before they appear as confusing bugs at 11pm before a deadline – is one of the highest-value sections of this vue.js tutorial. Each pitfall includes a clear before/after example.

Pitfall 1: Destructuring Reactive Objects Loses Reactivity

Destructuring a reactive() object or a Pinia store using plain JavaScript destructuring breaks Vue’s reactivity. The resulting variables are plain JavaScript values – they are no longer connected to Vue’s reactive proxy system, so the template will not update when the underlying data changes. This is one of the most common sources of β€œmy UI is not updating” bugs for developers new to Vue 3.





Pitfall 2: Using v-if and v-for on the Same Element

Placing v-if and v-for on the same element is explicitly discouraged in the Vue style guide. In Vue 3, v-if has higher precedence than v-for (the reverse of Vue 2), meaning the condition is evaluated before the loop variable is in scope. This causes a runtime error or silently skipped rendering that is difficult to debug.





Pitfall 3: Directly Mutating Props

Vue enforces one-way data flow – child components should never mutate props received from their parent. Doing so breaks the predictability of the component tree, causes Vue to emit a runtime warning, and can lead to subtle bugs when the parent re-renders and overwrites the mutated value. The correct pattern is to emit an event and let the parent update the source of truth.





Pitfall 4: Accessing Browser APIs During SSR. If you later add server-side rendering via Nuxt 4, calling localStorage.getItem() at module load time will throw ReferenceError: localStorage is not defined because that global does not exist in Node.js. Always guard browser API access with if (typeof window !== 'undefined') or move access inside onMounted, which only runs on the client. The useLocalStorage composable in this tutorial already demonstrates this pattern.

Pitfall 5: Using Array Index as the :key in v-for. The key attribute tells Vue how to identify DOM nodes when a list changes. Using array index as the key means Vue cannot distinguish between β€œthis item moved” and β€œthis item was replaced with a different one.” The result is incorrect animations, stale component state after deletions or reorders, and subtle rendering bugs. Always use a stable, unique identifier – like the id field generated by nanoid in this tutorial – as the key.

Pitfall 6: Overusing watch When computed Is Appropriate. A frequent antipattern is watching a reactive value and using the watcher to derive another value. This is harder to read, more error-prone, and less efficient than a computed property. Computed properties are automatically cached (they only recompute when dependencies change) and do not create timing edge cases. Reserve watch and watchEffect for side effects: API calls, localStorage writes, triggering animations, and integrating with third-party DOM libraries.

Troubleshooting Guide

When things go wrong during development, a systematic reference saves significant debugging time. The table below covers the most common issues across the toolchain used in this guide – from environment setup through deployment.

SymptomLikely CauseSolution
Template type errors in VS Code despite correct codeVetur extension active alongside Vue – OfficialDisable Vetur completely; only Vue – Official (Volar) should handle .vue files
Pinia state not updating in templateStore destructured without storeToRefsUse storeToRefs for reactive state properties; destructure actions directly
ReferenceError: localStorage is not definedSSR environment or Node.js test contextGuard with typeof window !== 'undefined'; use the test-setup.ts localStorage mock for Vitest
Tailwind CSS classes not applied in browser@tailwindcss/vite plugin missing from vite.config.tsAdd tailwindcss() to the plugins array and restart the dev server
Hot module replacement stops workingBrowser extension blocking WebSocket connectionDisable extensions for localhost, or run with pnpm dev --host
TypeScript error: β€œProperty does not exist on type never” in templateMissing or incorrect defineProps type annotationUse generic form: defineProps<{ task: Task }>() and import the type
Route change does not scroll to top of pagescrollBehavior not set in router configAdd scrollBehavior: () => ({ top: 0 }) to createRouter options
Vitest: β€œCannot use import statement outside a module”ESM-only package not transformed by ViteAdd the package to server.deps.inline in vitest config (e.g., nanoid)
TransitionGroup animations not runningMissing :key on list items or missing .move CSS classAdd unique :key to each item; add .list-move { transition: transform 0.25s ease; }
pnpm install fails with peer dependency errorsIncompatible Node.js versionRun node -v to verify version; upgrade to Node 22+ with nvm or Volta

For issues not covered in this table, the official Vue.js documentation and the Vue Land Discord server (linked from the Vue GitHub repository) are the most reliable resources. The Vue team and community are consistently responsive to well-described issues with minimal reproduction cases.

Advanced Tips and Next Steps

The Task Manager built across this vue.js 3 tutorial demonstrates every foundational Vue 3 pattern. The following advanced topics represent the natural next learning investments for developers who want to build larger, more resilient, and better-performing applications in the Vue ecosystem.

Vapor Mode and the Vue 3.6 Roadmap

Vapor Mode is a Vue compiler optimization strategy that generates direct DOM operations instead of a virtual DOM tree. Components compiled in Vapor Mode skip the reconciliation pass entirely, delivering 2–4x rendering performance improvements for components with highly dynamic content. As of Vue 3.6.0-beta.9 (April 2026), Vapor Mode is actively being iterated and supports a growing subset of Vue template features. You opt components in individually with a compiler flag – it is not an all-or-nothing migration. Watch the Vue core GitHub discussions for stable availability timelines.

Alongside Vapor Mode, Vue 3.6 previews alien signals – a complete rewrite of the reactivity internals that makes Vue’s signal primitives composable with signal implementations from other frameworks (Solid, Preact, Angular). For existing application code, this change is transparent. For library authors building cross-framework utilities, it opens significant interoperability opportunities. Both features are expected to stabilize in Vue 3.6 stable, targeted for mid-2026.

Scaling to Full-Stack with Nuxt 4

The application built in this tutorial is a client-side SPA. For applications requiring server-side rendering, static site generation, edge functions, or API routes, Nuxt 4.4 (latest as of March 2026) is the recommended meta-framework. Nuxt provides file-system-based routing, automatic code splitting, server-side data fetching with useAsyncData and useFetch, a powerful module ecosystem, and first-class Nitro server engine integration. Migrating this Task Manager to Nuxt would primarily involve moving views to pages/, replacing the manual router config with Nuxt’s convention-based routing, and updating the Vite config to a Nuxt config.

CapabilityTool / LibraryWhy Choose It in 2026
Server-side renderingNuxt 4.4Official Vue meta-framework; SSR, SSG, ISR, edge rendering
Async server statePinia Colada (stable 2026)Caching, deduplication, background revalidation for API data
Component testingVue Test Utils + Vitest 4.1Official component mounting API; integrates with Vite natively
E2E testingPlaywrightSuperior reliability and TypeScript DX over Cypress in 2026
Complex form handlingVeeValidate 4 or FormKitSchema validation (Zod/Yup), field arrays, async validation
Headless UI componentsRadix Vue or shadcn-vueFully accessible, Tailwind-styled, zero lock-in
Data visualizationECharts via vue-echartsThorough chart types, Vue 3 integration, tree-shakeable

Build tooling decisions matter significantly as projects scale. Our Vite vs Webpack 2026 guide covers when Rolldown-powered Vite 8 outperforms webpack and when legacy setups remain relevant. For teams making UI library decisions, our Tailwind CSS vs Bootstrap 2026 analysis provides a framework-agnostic evaluation of both approaches.

For developers looking to accelerate the kind of workflow shown in this guide using AI code completion, intelligent refactoring, and context-aware documentation, the AI coding tools guide reviews the current generation of tools specifically in the context of TypeScript and Vue development.

FAQ

Is Vue.js 3 still worth learning in 2026?

Absolutely. Vue 3 consistently ranks in the top tier of developer satisfaction in the State of JS and Stack Overflow surveys. The ecosystem is stable and on a strong forward trajectory – Vue 3.5.31 is a rock-solid release for production applications, while Vue 3.6 brings meaningful performance improvements without breaking changes. For teams that value progressive adoption, a gentle learning curve, and excellent TypeScript support, Vue 3 remains one of the strongest frontend choices in 2026. It is also the dominant framework in Asia-Pacific markets and continues to gain adoption in Europe.

What is the difference between Pinia and Vuex?

Pinia is the official successor to Vuex for Vue 3 applications and should be used for all new projects. The key differences: Pinia eliminates the mutation layer (you modify state directly), has full TypeScript inference without augmentation boilerplate, supports multiple flat stores without a root store, and its setup store syntax reads identically to the Composition API. Vuex is in maintenance-only mode – it receives security patches but no new features – and its verbose mutation/action split is widely considered unnecessary complexity that Pinia correctly removes.

Should I use the Options API or the Composition API for new Vue 3 projects?

The Composition API is recommended for new projects in 2026. It provides significantly better TypeScript type inference, makes logic reuse via composables far cleaner than the mixin pattern, and produces more readable components as complexity grows. The Options API remains fully supported and is not deprecated – if your team is more comfortable with it or you are maintaining an existing codebase, there is no urgency to rewrite. Both APIs can coexist in the same project. If you are starting from zero, invest the time learning the Composition API – it pays dividends as soon as components grow past simple cases.

What is Vapor Mode and should I use it in production today?

Vapor Mode is a Vue compiler optimization that removes the virtual DOM for opted-in components, generating direct DOM manipulation code instead. It can deliver 2–4x rendering performance improvements for heavily dynamic components. As of April 2026 (Vue 3.6.0-beta.9), Vapor Mode is not yet production-ready for general use – it covers a growing but incomplete subset of Vue template features. Do not use it in production today unless you are building an experimental project and are prepared to handle edge cases. Follow the Vue 3.6 release notes for stable availability announcements, expected in mid-to-late 2026.

How does this vue.js project tutorial compare to a real production codebase?

The Task Manager covers every core pattern you will use professionally: the Composition API with TypeScript, typed Pinia stores with computed properties and actions, Vue Router with code splitting, composables for shared logic, Tailwind CSS for styling, and Vitest for unit testing. Production applications additionally require: authentication (JWT with refresh tokens, or an auth library), API integration with error handling and loading states (Pinia Colada for server state), error boundaries, internationalization (vue-i18n), accessibility auditing, CI/CD pipelines, and often an end-to-end test suite (Playwright). The architectural patterns introduced here scale to all of these without significant rework.

Should I start new projects with Nuxt or plain Vue?

Start with Nuxt if: your application needs SEO (marketing pages, blogs, e-commerce), you require server-side rendering for performance or data freshness, or you want to co-locate API routes with your frontend. Start with plain Vue + Vite if: you are building an authenticated SPA (dashboards, internal tools, web apps behind a login) where SEO is irrelevant, or you want the simplest possible setup. Nuxt adds meaningful power but also meaningful complexity. Do not reach for it reflexively – but when your requirements match its strengths, it is an excellent choice.

What is Pinia Colada and when should I use it?

Pinia Colada is a data fetching library for Vue 3 that complements Pinia by handling server state – data fetched from APIs that needs caching, deduplication, background revalidation, and optimistic updates. Pinia itself handles client state (UI state, user preferences, locally created data). Pinia Colada reached its first stable release in early 2026 and is now the recommended approach for API-integrated Vue applications, offering similar capabilities to TanStack Query but designed specifically for the Vue ecosystem. If your application makes more than trivial API calls, Pinia Colada is worth evaluating immediately.

How do I deploy this application to production?

Run pnpm build to generate a static dist/ folder. Deploy its contents to any static hosting provider: Vercel, Netlify, Cloudflare Pages, or GitHub Pages all offer free tiers with automatic Git-based deployments and preview environments for pull requests. Because this application uses createWebHistory, you must configure your hosting platform to serve index.html for all routes – this is the SPA fallback, and without it, users who navigate directly to a non-root URL or refresh the browser will receive a 404 error. All major platforms support this configuration with a single setting or a redirect rule file.

Related Coverage

More Framework Guides and Comparisons

  • React vs Vue 2026 – A data-driven comparison of ecosystem size, performance benchmarks, hiring market depth, and developer satisfaction scores to help teams choose between the two most popular frontend frameworks.
  • Angular vs React 2026 – Enterprise teams evaluating long-term framework commitments will find opinionated guidance based on team size, project duration, and backend technology stack.
  • Next.js full-stack tutorial – Build a comparable full-stack application using the React ecosystem, providing useful architectural context for understanding the tradeoffs between Vue and React approaches to the same problems.
  • Vite vs Webpack 2026 – A deep-dive into build tool performance, configuration complexity, and migration paths, including Vite 8’s new Rolldown bundler benchmarks on real-world project sizes.
  • TypeScript vs JavaScript 2026 – A balanced analysis for teams deciding whether to adopt TypeScript, with current data on productivity impact, bug detection rates, and developer onboarding time.
  • Tailwind CSS vs Bootstrap 2026 – Styling framework comparison covering bundle size, customization workflow, design system integration, and team adoption considerations for both Tailwind v4 and Bootstrap 5.3.
πŸ‘ Marcus Chen

Marcus Chen

Senior Tech Reporter

Marcus Chen is a Senior Tech Reporter at Tech Insider covering cloud computing, enterprise software, and the business of technology. Before joining TI, he spent five years at ZDNet covering digital transformation across European enterprises and three years at The Register reporting on cloud infrastructure. Marcus is known for his deep dives into cloud cost optimization and multi-cloud strategy. He holds a degree in Computer Science from Imperial College London and speaks regularly at KubeCon and CloudNative events.

View all articles
πŸ‘ Tech Insider
Tech
Insider

Tech Insider delivers in-depth coverage of the technologies shaping the future: AI, cybersecurity, cloud computing, hardware, and the trends that matter.

Company

Explore

Categories

Β© 2026 Tech Insider Media AB. All rights reserved.