On this page

Vue Router 4: routes, navigation guards, and lazy loading

15 min read TextCh. 3 — Composition

Vue Router overview

Vue Router is the official routing library for Vue.js. It integrates deeply with Vue's reactivity system: the current route is a reactive object, navigation triggers component re-renders, and route metadata flows through your application just like any other reactive data.

Vue Router 4 (for Vue 3) supports:

  • File-based or code-based route definitions
  • Nested routes with <RouterView> outlets
  • Dynamic segments and catch-all routes
  • History API and hash-based navigation
  • Navigation guards (global, per-route, in-component)
  • Lazy-loaded route components
  • Named routes and named views
  • Route meta fields for guards and title management

Installation and setup

npm install vue-router

Vue Router is included by default when you choose it during npm create vue@latest.

Defining routes

Routes are defined as an array of RouteRecordRaw objects:

import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(), // uses the HTML5 History API
  routes: [
    {
      path: '/',
      component: () => import('@/views/HomeView.vue'),
    },
    {
      path: '/users/:id', // dynamic segment
      component: () => import('@/views/UserView.vue'),
    },
  ],
})

History modes

Mode URL format Setup required
createWebHistory() /users/1 Server must serve index.html for all paths
createWebHashHistory() /#/users/1 No server config needed
createMemoryHistory() (none) For SSR and testing

Dynamic route segments

Use :paramName to match variable segments. Multiple params are allowed:

{ path: '/users/:userId/posts/:postId', component: PostView }

Access params in the component:

import { useRoute } from 'vue-router'
const route = useRoute()
console.log(route.params.userId)  // string
console.log(route.params.postId)  // string

Prop passing: Set props: true on the route to receive params as component props — cleaner than accessing useRoute() inside the component.

{ path: '/users/:id', component: UserView, props: true }
// UserView receives id as a prop

Nested routes

Nested routes render inside the parent's <RouterView>:

{
  path: '/settings',
  component: SettingsLayout,
  children: [
    { path: '', component: SettingsGeneral },    // /settings
    { path: 'profile', component: SettingsProfile }, // /settings/profile
    { path: 'security', component: SettingsSecurity }, // /settings/security
  ],
}

SettingsLayout.vue must contain a <RouterView /> to render the active child.

<RouterLink> renders an <a> tag with proper handling for the Vue Router history mode:

<!-- Basic link -->
<RouterLink to="/about">About</RouterLink>

<!-- Named route with params -->
<RouterLink :to="{ name: 'UserDetail', params: { id: user.id } }">
  {{ user.name }}
</RouterLink>

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

Active classes are applied automatically:

  • router-link-active — route is a partial match (parent route)
  • router-link-exact-active — route is an exact match

<RouterView> is the outlet where the matched component renders. Use the slot to add transitions:

<RouterView v-slot="{ Component }">
  <Transition name="slide">
    <component :is="Component" :key="$route.path" />
  </Transition>
</RouterView>

Programmatic navigation

Use useRouter() for imperative navigation:

import { useRouter } from 'vue-router'

const router = useRouter()

// Navigate to a path
router.push('/dashboard')

// Named route
router.push({ name: 'UserDetail', params: { id: 42 } })

// With query
router.push({ path: '/search', query: { q: 'vue' } })

// Replace (no history entry)
router.replace('/login')

// Go back / forward
router.go(-1)
router.back()
router.forward()

Guards let you control navigation — redirect unauthenticated users, confirm unsaved changes, or load data before displaying a route.

Global guards

// Before each navigation
router.beforeEach(async (to, from) => {
  // Returning false cancels navigation
  // Returning a route object redirects
  // Returning nothing (or true) allows navigation
  if (to.meta.requiresAuth && !isAuthenticated()) {
    return { name: 'Login', query: { redirect: to.fullPath } }
  }
})

// After each navigation (no next() — cannot cancel)
router.afterEach((to, from, failure) => {
  if (!failure) trackPageView(to.fullPath)
})

Per-route guards

{
  path: '/admin',
  component: AdminView,
  beforeEnter: [(to) => {
    if (!isAdmin()) return { name: 'Forbidden' }
  }],
}

In-component guards with the Composition API

import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'

// Runs before leaving this route
onBeforeRouteLeave((to, from) => {
  if (hasUnsavedChanges.value) {
    const confirmed = window.confirm('You have unsaved changes. Leave anyway?')
    if (!confirmed) return false
  }
})

// Runs when params change but component is reused (e.g., /users/1 → /users/2)
onBeforeRouteUpdate(async (to) => {
  await loadUser(to.params.id as string)
})

Route meta fields

Add arbitrary metadata to routes via the meta field:

declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean
    title?: string
    roles?: string[]
  }
}

const routes = [
  {
    path: '/dashboard',
    component: Dashboard,
    meta: { requiresAuth: true, title: 'Dashboard', roles: ['admin'] },
  },
]

Access meta in guards, afterEach, or inside components via route.meta.

Lazy loading and code splitting

All route components should be lazy loaded:

// Automatic code splitting — Vite creates a separate chunk per route
component: () => import('@/views/HeavyView.vue')

// Named chunks (grouped in one file)
component: () => import(/* webpackChunkName: "admin" */ '@/views/AdminView.vue')

For routes that are almost always visited (like Home), you can prefetch them:

component: () => import(/* webpackPrefetch: true */ '@/views/HomeView.vue')

Scroll behavior

Control the scroll position after navigation:

const router = createRouter({
  scrollBehavior(to, from, savedPosition) {
    // Restore scroll position when using browser back/forward
    if (savedPosition) return savedPosition

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

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

Practice

  1. Protected route: Create a /profile route that requires authentication. Implement a global beforeEach guard that redirects unauthenticated users to /login and stores the intended destination in a redirect query param. After login, redirect to the stored destination.
  2. Nested routes: Build a /settings layout with three child routes: general, notifications, and security. Each child should be a separate view component loaded lazily.
  3. Route transitions: Wrap <RouterView> with a <Transition> component to add a fade animation when navigating between routes.

In the next lesson, we will explore Pinia — the official state management library for Vue 3 — with stores, getters, and actions.

Always use lazy loading for route components
Use () => import('./views/MyView.vue') instead of a static import. Vite will automatically code-split each view into a separate chunk, so users only download the code for the page they are visiting.
useRoute and useRouter require active instance
useRoute() and useRouter() must be called inside setup() or a composable that runs during setup. Calling them outside (e.g., in a plain function at module level) returns an error because there is no active component instance.
Named routes for refactoring safety
Use named routes (name: 'UserDetail') and navigate with router.push({ name: 'UserDetail', params: { id } }) instead of hardcoded paths. If you ever change a URL, you only update the route definition — not every RouterLink and push() call.
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    component: () => import('@/views/HomeView.vue'),
    meta: { title: 'Home' },
  },
  {
    path: '/about',
    component: () => import('@/views/AboutView.vue'),
    meta: { title: 'About' },
  },
  {
    path: '/users',
    component: () => import('@/views/UsersView.vue'),
    meta: { requiresAuth: true, title: 'Users' },
    children: [
      {
        path: ':id',
        component: () => import('@/views/UserDetailView.vue'),
        props: true, // route.params.id is passed as a prop
        meta: { title: 'User Detail' },
      },
    ],
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import('@/views/NotFoundView.vue'),
  },
]

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

// Navigation guard — runs before each route
router.beforeEach(async (to) => {
  const auth = useAuthStore()

  // Update document title from route meta
  document.title = String(to.meta.title ?? 'My App')

  // Redirect unauthenticated users
  if (to.meta.requiresAuth && !auth.isLoggedIn) {
    return { path: '/login', query: { redirect: to.fullPath } }
  }
})