7.6: Vue Router

Master state management with Pinia, the official state management library for Vue 3 applications. Learn how to manage global application state, share data between components, and implement centralized state patterns.

1. Introduction to Vue Router

What is Client-Side Routing?

Traditional multi-page apps:

  • Each page is a separate HTML file
  • Browser makes full page reload on navigation
  • Server sends new HTML for each page

Single Page Applications (SPAs):

  • One HTML file for entire app
  • JavaScript dynamically updates content
  • No full page reloads (faster navigation)
  • Vue Router handles URL changes

Benefits:

  • Faster navigation (no page reloads)
  • Smooth transitions between pages
  • Better user experience
  • Maintains application state
  • Works offline (with PWA)

2. Setting Up Vue Router

Installation

When creating a Vue project:

npm create vue@latest

# Select "Yes" for Vue Router
 Add Vue Router for Single Page Application development? Yes

Manual installation:

npm install vue-router@4

Basic Setup

Create router configuration:

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

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

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

export default router

Register router in main app:

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

const app = createApp(App)

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

Use router in root component:

<!-- App.vue -->
<script setup>
import { RouterLink, RouterView } from 'vue-router'
</script>

<template>
  <div id="app">
    <nav>
      <RouterLink to="/">Home</RouterLink>
      <RouterLink to="/about">About</RouterLink>
    </nav>

    <main>
      <RouterView />
    </main>
  </div>
</template>

3. Basic Routing

Creating Routes

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    name: 'home',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/about',
    name: 'about',
    component: () => import('@/views/About.vue')
  },
  {
    path: '/contact',
    name: 'contact',
    component: () => import('@/views/Contact.vue')
  },
  {
    path: '/products',
    name: 'products',
    component: () => import('@/views/Products.vue')
  }
]

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

export default router

Lazy loading vs Direct import:

// Direct import - included in initial bundle
import Home from '@/views/Home.vue'

// Lazy loading - separate chunk, loaded on demand
component: () => import('@/views/About.vue')
<template>
  <nav>
    <!-- Basic navigation -->
    <RouterLink to="/">Home</RouterLink>
    <RouterLink to="/about">About</RouterLink>

    <!-- Named routes -->
    <RouterLink :to="{ name: 'products' }">Products</RouterLink>

    <!-- With parameters -->
    <RouterLink :to="{ name: 'user', params: { id: 123 } }">
      User Profile
    </RouterLink>

    <!-- With query parameters -->
    <RouterLink :to="{ path: '/search', query: { q: 'vue' } }">
      Search
    </RouterLink>

    <!-- External link (use regular <a> tag) -->
    <a href="https://vuejs.org" target="_blank">Vue Docs</a>
  </nav>
</template>

<style scoped>
/* Active link styling */
.router-link-active {
  font-weight: bold;
  color: #42b983;
}

/* Exact active (only when exact match) */
.router-link-exact-active {
  border-bottom: 2px solid #42b983;
}
</style>

Programmatic Navigation

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

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

// Navigate to route
function goToAbout() {
  router.push('/about')
}

// Navigate with name
function goToUser(userId) {
  router.push({ name: 'user', params: { id: userId } })
}

// Navigate with query
function searchProducts(query) {
  router.push({ path: '/products', query: { search: query } })
}

// Replace current route (doesn't add to history)
function replaceRoute() {
  router.replace('/about')
}

// Go back
function goBack() {
  router.back()
}

// Go forward
function goForward() {
  router.forward()
}

// Go to specific history entry
function goToHistory(delta) {
  router.go(delta) // -1 = back, 1 = forward
}

// Get current route info
console.log('Current path:', route.path)
console.log('Route params:', route.params)
console.log('Route query:', route.query)
</script>

<template>
  <div>
    <button @click="goToAbout">Go to About</button>
    <button @click="goBack">Back</button>
    <button @click="searchProducts('laptop')">Search Laptops</button>
  </div>
</template>

4. Dynamic Routes

Route Parameters

// router/index.js
const routes = [
  {
    path: '/user/:id',
    name: 'user',
    component: () => import('@/views/UserProfile.vue')
  },
  {
    path: '/product/:category/:id',
    name: 'product',
    component: () => import('@/views/ProductDetail.vue')
  },
  {
    // Optional parameter
    path: '/search/:query?',
    name: 'search',
    component: () => import('@/views/Search.vue')
  }
]

Accessing parameters:

<!-- views/UserProfile.vue -->
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const user = ref(null)

// Access parameter
const userId = route.params.id

// Fetch user data
async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`)
  user.value = await response.json()
}

// Fetch on mount
fetchUser(userId)

// Re-fetch when route changes
watch(
  () => route.params.id,
  (newId) => {
    fetchUser(newId)
  }
)
</script>

<template>
  <div v-if="user">
    <h1>{{ user.name }}</h1>
    <p>User ID: {{ route.params.id }}</p>
  </div>
</template>

Props Mode

Pass route params as props to component:

// router/index.js
const routes = [
  {
    path: '/user/:id',
    name: 'user',
    component: () => import('@/views/UserProfile.vue'),
    props: true // Pass params as props
  },
  {
    path: '/product/:id',
    name: 'product',
    component: () => import('@/views/ProductDetail.vue'),
    props: (route) => ({
      id: Number(route.params.id), // Convert to number
      highlight: route.query.highlight
    })
  }
]

Component receives props:

<!-- views/UserProfile.vue -->
<script setup>
import { ref, watch } from 'vue'

const props = defineProps({
  id: {
    type: String,
    required: true
  }
})

const user = ref(null)

async function fetchUser(userId) {
  const response = await fetch(`/api/users/${userId}`)
  user.value = await response.json()
}

fetchUser(props.id)

watch(() => props.id, (newId) => {
  fetchUser(newId)
})
</script>

<template>
  <div v-if="user">
    <h1>{{ user.name }}</h1>
  </div>
</template>

5. Nested Routes

Creating Nested Routes

// router/index.js
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
    children: [
      {
        path: '', // Default child route
        name: 'dashboard-home',
        component: () => import('@/views/dashboard/Home.vue')
      },
      {
        path: 'profile',
        name: 'dashboard-profile',
        component: () => import('@/views/dashboard/Profile.vue')
      },
      {
        path: 'settings',
        name: 'dashboard-settings',
        component: () => import('@/views/dashboard/Settings.vue')
      },
      {
        path: 'posts/:id',
        name: 'dashboard-post',
        component: () => import('@/views/dashboard/PostDetail.vue')
      }
    ]
  }
]

Parent component with nested RouterView:

<!-- views/Dashboard.vue -->
<script setup>
import { RouterLink, RouterView } from 'vue-router'
</script>

<template>
  <div class="dashboard">
    <aside class="sidebar">
      <h2>Dashboard</h2>
      <nav>
        <RouterLink to="/dashboard">Home</RouterLink>
        <RouterLink to="/dashboard/profile">Profile</RouterLink>
        <RouterLink to="/dashboard/settings">Settings</RouterLink>
      </nav>
    </aside>

    <main class="content">
      <!-- Child routes render here -->
      <RouterView />
    </main>
  </div>
</template>

<style scoped>
.dashboard {
  display: flex;
  min-height: 100vh;
}

.sidebar {
  width: 250px;
  background: #f5f5f5;
  padding: 1rem;
}

.content {
  flex: 1;
  padding: 2rem;
}

nav a {
  display: block;
  padding: 0.5rem 1rem;
  margin-bottom: 0.5rem;
  border-radius: 4px;
  text-decoration: none;
  color: #333;
}

nav a.router-link-active {
  background: #42b983;
  color: white;
}
</style>

6. Navigation Guards

Global Guards

// router/index.js
const router = createRouter({
  history: createWebHistory(),
  routes
})

// Global before guard (runs before every navigation)
router.beforeEach((to, from, next) => {
  console.log('Navigating to:', to.path)
  console.log('Coming from:', from.path)

  // Check authentication
  const isAuthenticated = localStorage.getItem('token')

  if (to.meta.requiresAuth && !isAuthenticated) {
    // Redirect to login
    next({ name: 'login' })
  } else {
    // Allow navigation
    next()
  }
})

// Global after guard (runs after navigation is confirmed)
router.afterEach((to, from) => {
  // Update page title
  document.title = to.meta.title || 'My App'

  // Analytics tracking
  trackPageView(to.path)
})

export default router

Per-Route Guards

const routes = [
  {
    path: '/admin',
    component: () => import('@/views/Admin.vue'),
    meta: { requiresAuth: true, requiresAdmin: true },
    beforeEnter: (to, from, next) => {
      const user = getUserFromToken()

      if (!user || !user.isAdmin) {
        next({ name: 'home' })
      } else {
        next()
      }
    }
  }
]

In-Component Guards

<script setup>
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { ref } from 'vue'

const hasUnsavedChanges = ref(false)

// Confirm before leaving if there are unsaved changes
onBeforeRouteLeave((to, from) => {
  if (hasUnsavedChanges.value) {
    const answer = window.confirm('You have unsaved changes. Leave anyway?')
    if (!answer) return false
  }
})

// React to route parameter changes
onBeforeRouteUpdate((to, from) => {
  // Fetch new data when route params change
  fetchData(to.params.id)
})
</script>

7. Route Meta Fields

const routes = [
  {
    path: '/',
    name: 'home',
    component: Home,
    meta: {
      title: 'Home Page',
      requiresAuth: false,
      layout: 'default'
    }
  },
  {
    path: '/admin',
    name: 'admin',
    component: Admin,
    meta: {
      title: 'Admin Panel',
      requiresAuth: true,
      requiresAdmin: true,
      layout: 'admin'
    }
  },
  {
    path: '/profile',
    name: 'profile',
    component: Profile,
    meta: {
      title: 'My Profile',
      requiresAuth: true,
      breadcrumb: 'Profile'
    }
  }
]

// Use meta in navigation guard
router.beforeEach((to, from, next) => {
  // Set page title
  document.title = to.meta.title || 'App'

  // Check authentication
  if (to.meta.requiresAuth) {
    const isAuthenticated = checkAuth()
    if (!isAuthenticated) {
      next({ name: 'login' })
      return
    }
  }

  // Check admin access
  if (to.meta.requiresAdmin) {
    const isAdmin = checkAdminStatus()
    if (!isAdmin) {
      next({ name: 'home' })
      return
    }
  }

  next()
})

8. Scroll Behavior

const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior(to, from, savedPosition) {
    // Saved position (when using back/forward buttons)
    if (savedPosition) {
      return savedPosition
    }

    // Scroll to anchor
    if (to.hash) {
      return {
        el: to.hash,
        behavior: 'smooth'
      }
    }

    // Always scroll to top
    return { top: 0 }
  }
})

9. Complete Example: Blog Application

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    name: 'home',
    component: () => import('@/views/Home.vue'),
    meta: { title: 'Home' }
  },
  {
    path: '/blog',
    component: () => import('@/views/Blog.vue'),
    meta: { title: 'Blog' },
    children: [
      {
        path: '',
        name: 'blog-list',
        component: () => import('@/views/blog/PostList.vue')
      },
      {
        path: ':slug',
        name: 'blog-post',
        component: () => import('@/views/blog/PostDetail.vue'),
        props: true
      },
      {
        path: 'category/:category',
        name: 'blog-category',
        component: () => import('@/views/blog/Category.vue'),
        props: true
      }
    ]
  },
  {
    path: '/login',
    name: 'login',
    component: () => import('@/views/Login.vue'),
    meta: { title: 'Login', layout: 'auth' }
  },
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: { requiresAuth: true, title: 'Dashboard' },
    children: [
      {
        path: '',
        name: 'dashboard-home',
        component: () => import('@/views/dashboard/Home.vue')
      },
      {
        path: 'posts',
        name: 'dashboard-posts',
        component: () => import('@/views/dashboard/Posts.vue')
      },
      {
        path: 'posts/new',
        name: 'dashboard-new-post',
        component: () => import('@/views/dashboard/NewPost.vue')
      },
      {
        path: 'posts/:id/edit',
        name: 'dashboard-edit-post',
        component: () => import('@/views/dashboard/EditPost.vue'),
        props: true
      }
    ]
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'not-found',
    component: () => import('@/views/NotFound.vue'),
    meta: { title: '404 Not Found' }
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { top: 0 }
    }
  }
})

// Navigation guards
router.beforeEach((to, from, next) => {
  // Update title
  document.title = to.meta.title || 'My Blog'

  // Check auth
  if (to.meta.requiresAuth) {
    const isAuthenticated = localStorage.getItem('token')
    if (!isAuthenticated) {
      next({ name: 'login', query: { redirect: to.fullPath } })
      return
    }
  }

  next()
})

export default router

10. Practical Exercises

Exercise 5.6.1: Multi-Page Portfolio

Create a portfolio site with:

  1. Home, About, Projects, Contact pages
  2. Dynamic project detail pages
  3. Smooth page transitions
  4. Active link highlighting

Exercise 5.6.2: E-commerce Navigation

Build product browsing:

  1. Product list by category
  2. Product detail pages
  3. Search with query parameters
  4. Breadcrumb navigation

Exercise 5.6.3: Protected Routes

Implement authentication:

  1. Login/logout functionality
  2. Protected admin routes
  3. Redirect to login if not authenticated
  4. Redirect to intended page after login

Exercise 5.6.4: Nested Dashboard

Create admin dashboard:

  1. Sidebar navigation
  2. Nested routes (users, posts, settings)
  3. Route guards for admin-only access
  4. Breadcrumbs showing current location

11. Knowledge Check

Question 1: What's the difference between push and replace?

Show answer `push` adds a new entry to browser history (can go back). `replace` replaces current entry (can't go back to replaced route). Use `replace` for redirects after login/logout.

Question 2: When should you use lazy loading for routes?

Show answer Use lazy loading for routes that aren't immediately needed (like admin pages, less-visited pages). This reduces initial bundle size and improves initial load time.

Question 3: What are navigation guards used for?

Show answer Navigation guards control route access, check authentication, confirm leaving unsaved changes, set page titles, track analytics, and handle redirects.

Question 4: How do nested routes work?

Show answer Nested routes render inside parent route's ``. They create hierarchical URLs (/dashboard/profile) and allow shared layouts with different content.

12. Key Takeaways

  • Vue Router enables SPAs with client-side navigation
  • RouterLink for declarative navigation
  • useRouter/useRoute for programmatic navigation
  • Dynamic routes use parameters () for flexible URLs
  • Nested routes create hierarchical page structures
  • Navigation guards control route access and behavior
  • Lazy loading improves performance
  • Meta fields store route-specific information
  • Props mode passes route params as component props
  • Scroll behavior customizes scroll position on navigation

13. Further Resources

Official Documentation:


Next Steps

Excellent! You now know how to build multi-page Vue applications with Vue Router.

In Lesson 5.7: State Management with Pinia, you'll learn how to manage global application state across components.