Skip to main content
Technology & EngineeringVue307 lines

Vue Router

Vue Router 4 advanced patterns including navigation guards, lazy loading, scroll behavior, and typed routes

Quick Summary18 lines
You are an expert in Vue Router 4 advanced features for building robust, type-safe routing in Vue 3 applications.

## Key Points

- **Navigation guards** — hooks that run before, during, or after navigation to control access and side effects
- **Route meta** — custom metadata attached to routes for use in guards and components
- **Lazy loading** — dynamic imports to code-split pages and reduce initial bundle size
- **Scroll behavior** — control scroll position when navigating between routes
- **Dynamic routes** — add or remove routes at runtime for plugin architectures
- **Typed routes** — TypeScript integration for route names, params, and meta
1. **Use `beforeEach` for authentication** — a single global guard is cleaner than per-route checks spread across the codebase.
2. **Always lazy-load page components** — use `() => import(...)` for every route component to enable code splitting.
3. **Type your route meta** — augment `RouteMeta` via module declaration so guards and components have type-safe access.
4. **Use named routes for navigation** — `{ name: 'user', params: { id: '1' } }` is more robust than hardcoding paths.
5. **Implement scroll behavior** — restore saved position for back navigation, scroll to top for new navigations, and support hash anchors.
6. **Centralize guard logic** — keep guards in a separate file and register them in a function for testability.
skilldb get vue-skills/Vue RouterFull skill: 307 lines
Paste into your CLAUDE.md or agent config

Vue Router Advanced Patterns — Vue.js

You are an expert in Vue Router 4 advanced features for building robust, type-safe routing in Vue 3 applications.

Core Philosophy

Vue Router is the backbone of any non-trivial Vue application, and its advanced features exist to enforce a clean separation between navigation concerns and component logic. Navigation guards centralize access control so individual components do not need to know about authentication. Route meta fields make routes self-describing so guards can be generic. Lazy loading ensures users only download the code they need. These are not optional niceties — they are the architectural patterns that keep routing maintainable as applications grow.

The design philosophy is declarative configuration with imperative escape hatches. Routes are defined as a static data structure (the route array), guards are registered as hooks on that structure, and the router resolves navigation as a deterministic pipeline of checks. When you need dynamic behavior — adding routes at runtime, programmatic navigation, or conditional transitions — the imperative API is available, but the declarative configuration remains the source of truth.

Type safety in routing is an underappreciated concern. Augmenting RouteMeta with a TypeScript interface, using named routes instead of hardcoded paths, and typing route params transform the router from a stringly-typed black box into a type-checked navigation system. This investment pays off immediately in refactoring confidence and guard correctness.

Anti-Patterns

  • Scattering Auth Checks Across Components — checking isAuthenticated inside individual page components instead of using a global beforeEach guard. This duplicates logic, is easy to forget on new routes, and creates inconsistent access control.

  • Infinite Redirect Loops — writing a guard that redirects to /login without excluding /login from the check, causing the router to bounce between the guard and the redirect target indefinitely.

  • Using router.push() Inside Guards — calling router.push() or router.replace() inside a beforeEach guard instead of returning { name: '...' } or a UrlTree. The imperative call can cause unexpected double navigations and race conditions.

  • Hardcoding Paths in Navigation — writing router.push('/users/123/settings') instead of router.push({ name: 'user-settings', params: { id: '123' } }). Hardcoded paths break silently when the route structure changes.

  • Expecting beforeEnter to Run on Param Changes — assuming beforeEnter fires when navigating from /users/1 to /users/2. It only runs when entering the route from a different route. Use onBeforeRouteUpdate for param-change detection within the same route.

Overview

Vue Router 4 is the official router for Vue 3. Beyond basic route matching, it offers navigation guards, route meta fields, lazy loading, scroll behavior control, dynamic route manipulation, and typed route definitions. These advanced patterns are essential for production applications with authentication, role-based access, analytics, and performance optimization.

Core Concepts

  • Navigation guards — hooks that run before, during, or after navigation to control access and side effects
  • Route meta — custom metadata attached to routes for use in guards and components
  • Lazy loading — dynamic imports to code-split pages and reduce initial bundle size
  • Scroll behavior — control scroll position when navigating between routes
  • Dynamic routes — add or remove routes at runtime for plugin architectures
  • Typed routes — TypeScript integration for route names, params, and meta

Implementation Patterns

Router Setup with Typed Meta

// router/index.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'

// Augment route meta type
declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean
    roles?: string[]
    title?: string
    transition?: string
  }
}

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    component: () => import('@/layouts/DefaultLayout.vue'),
    children: [
      {
        path: '',
        name: 'home',
        component: () => import('@/pages/HomePage.vue'),
        meta: { title: 'Home' },
      },
      {
        path: 'dashboard',
        name: 'dashboard',
        component: () => import('@/pages/DashboardPage.vue'),
        meta: { requiresAuth: true, title: 'Dashboard' },
      },
      {
        path: 'admin',
        name: 'admin',
        component: () => import('@/pages/AdminPage.vue'),
        meta: { requiresAuth: true, roles: ['admin'], title: 'Admin' },
      },
    ],
  },
  {
    path: '/login',
    name: 'login',
    component: () => import('@/pages/LoginPage.vue'),
    meta: { title: 'Log In' },
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'not-found',
    component: () => import('@/pages/NotFoundPage.vue'),
  },
]

const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    }
    if (to.hash) {
      return { el: to.hash, behavior: 'smooth' }
    }
    return { top: 0 }
  },
})

export default router

Global Navigation Guards

// router/guards.ts
import type { Router } from 'vue-router'
import { useAuthStore } from '@/stores/useAuthStore'

export function registerGuards(router: Router) {
  // Authentication guard
  router.beforeEach((to, from) => {
    const auth = useAuthStore()

    if (to.meta.requiresAuth && !auth.isAuthenticated) {
      return { name: 'login', query: { redirect: to.fullPath } }
    }

    if (to.meta.roles && !to.meta.roles.includes(auth.user?.role ?? '')) {
      return { name: 'home' }
    }
  })

  // Page title
  router.afterEach((to) => {
    document.title = to.meta.title
      ? `${to.meta.title} | My App`
      : 'My App'
  })

  // Loading indicator
  router.beforeEach(() => {
    // Start progress bar
    NProgress.start()
  })

  router.afterEach(() => {
    NProgress.done()
  })
}

Per-Route Guards

{
  path: 'settings',
  name: 'settings',
  component: () => import('@/pages/SettingsPage.vue'),
  beforeEnter: (to, from) => {
    // Runs only when entering this route (not on param changes)
    const auth = useAuthStore()
    if (!auth.user?.emailVerified) {
      return { name: 'verify-email' }
    }
  },
}

In-Component Guards with Composition API

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

const hasUnsavedChanges = ref(false)

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

onBeforeRouteUpdate((to, from) => {
  // Called when the route changes but this component is reused
  // e.g., /users/1 → /users/2
  console.log(`Param changed from ${from.params.id} to ${to.params.id}`)
})
</script>

Lazy Loading with Named Chunks

const routes = [
  {
    path: '/reports',
    component: () => import(/* webpackChunkName: "reports" */ '@/pages/ReportsPage.vue'),
  },
  {
    // Group related routes into a single chunk
    path: '/admin',
    component: () => import(/* webpackChunkName: "admin" */ '@/layouts/AdminLayout.vue'),
    children: [
      {
        path: 'users',
        component: () => import(/* webpackChunkName: "admin" */ '@/pages/admin/UsersPage.vue'),
      },
      {
        path: 'settings',
        component: () => import(/* webpackChunkName: "admin" */ '@/pages/admin/SettingsPage.vue'),
      },
    ],
  },
]

Dynamic Route Addition

// Useful for plugin systems or module-based architectures
const router = useRouter()

// Add a route at runtime
router.addRoute({
  path: '/plugin-page',
  name: 'plugin-page',
  component: () => import('@/plugins/PluginPage.vue'),
})

// Add a child route to an existing named route
router.addRoute('admin', {
  path: 'custom-panel',
  component: () => import('@/plugins/CustomPanel.vue'),
})

// Remove a route
const removeRoute = router.addRoute({ /* ... */ })
removeRoute() // call the returned function to remove it

Route Transitions

<!-- App.vue or layout -->
<template>
  <router-view v-slot="{ Component, route }">
    <transition :name="route.meta.transition || 'fade'" mode="out-in">
      <component :is="Component" :key="route.path" />
    </transition>
  </router-view>
</template>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

Waiting for Async Data Before Navigation

{
  path: '/users/:id',
  name: 'user-detail',
  component: () => import('@/pages/UserDetail.vue'),
  beforeEnter: async (to) => {
    try {
      const user = await fetchUser(to.params.id as string)
      // Store in a shared composable or Pinia store
      useUserDetailStore().setUser(user)
    } catch {
      return { name: 'not-found' }
    }
  },
}

Best Practices

  1. Use beforeEach for authentication — a single global guard is cleaner than per-route checks spread across the codebase.
  2. Always lazy-load page components — use () => import(...) for every route component to enable code splitting.
  3. Type your route meta — augment RouteMeta via module declaration so guards and components have type-safe access.
  4. Use named routes for navigation{ name: 'user', params: { id: '1' } } is more robust than hardcoding paths.
  5. Implement scroll behavior — restore saved position for back navigation, scroll to top for new navigations, and support hash anchors.
  6. Centralize guard logic — keep guards in a separate file and register them in a function for testability.
  7. Use route.meta for declarative config — mark routes with requiresAuth, roles, title in meta rather than checking paths in guards.

Common Pitfalls

  • Guard returning nothing vs. true — a guard that returns undefined (no explicit return) allows navigation. Only return false or return { name: '...' } blocks or redirects.
  • beforeEnter does not run on param changes — it only fires when entering the route from a different route. Use onBeforeRouteUpdate for param changes within the same route.
  • Infinite redirect loops — a guard that redirects to /login must exclude /login from the check, or it creates an infinite loop.
  • Using router.push in beforeEach — use return { name: '...' } instead of router.push() inside guards. router.push inside a guard can cause unexpected behavior.
  • Accessing stores before Pinia is ready — global guards registered during router creation may fire before app.use(pinia). Register guards after the app is fully initialized or use lazy store access.

Install this skill directly: skilldb add vue-skills

Get CLI access →