Beyond push() and go() — navigation guards, async route components, typed route params with unplugin-vue-router, scroll behavior, and meta fields that scale with your app.
Most Vue developers learn Vue Router from the top. They define some routes, drop a <RouterView /> in the template, call router.push() to navigate, and move on. That covers maybe 20% of what Vue Router 4 can do.
The other 80% — guards that protect routes before the component ever loads, lazy-loaded chunks that shrink your initial bundle, typed route params that make route.params.id an actual string instead of string | string[], meta fields that centralise per-route configuration, and scroll behaviour that makes your SPA feel like a real website — that’s where routing gets interesting and where most apps are underusing the tools they already have.
This post covers all of it, from the navigation guard lifecycle to unplugin-vue-router typed routes, with real patterns for each.
A note on Vue Router 5: In 2026, Vue Router 5 has shipped, which merges
unplugin-vue-routerinto the core package with no breaking changes. If you are on Vue Router 4 +unplugin-vue-router, the patterns here are identical — migration is mostly import path changes. Everything in this post applies to both.
The Navigation Guard Lifecycle
Before writing any guards, understand the order in which Vue Router calls them. The full resolution flow for a navigation is:
1. Navigation triggered
2. Call beforeRouteLeave guards in deactivated components
3. Call global beforeEach guards
4. Call beforeRouteUpdate guards in reused components
5. Call beforeEnter in route configs
6. Resolve async route components (lazy-loaded chunks)
7. Call beforeRouteEnter in activated components
8. Call global beforeResolve guards
9. Navigation confirmed
10. Call global afterEach hooks
11. DOM updates triggered
12. Call next() callbacks in beforeRouteEnter
Understanding this order matters. beforeResolve fires after lazy-loaded components are resolved (step 6) — making it the right place to check permissions that depend on a component being available. afterEach fires after confirmation but before DOM updates — making it the right place for analytics.
Global Guards
router.beforeEach — The Auth Gate
Global before guards are called in creation order, whenever a navigation is triggered. Guards may be resolved asynchronously, and the navigation is considered pending before all hooks have been resolved.
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach(async (to, from) => {
const auth = useAuthStore()
// Redirect to login if route requires auth and user is not authenticated
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return {
name: 'Login',
query: { redirect: to.fullPath },
}
}
// Redirect authenticated users away from guest-only pages
if (to.meta.guestOnly && auth.isAuthenticated) {
return { name: 'Dashboard' }
}
// Role-based access control
if (to.meta.requiredRole && auth.user?.role !== to.meta.requiredRole) {
return { name: 'Forbidden' }
}
})
In previous versions of Vue Router, it was also possible to use a third argument next — this was a common source of mistakes. The modern Vue Router 4 approach returns false to cancel the navigation, a route location object to redirect, or nothing/undefined/true to proceed.
// ✗ Old pattern — using next (still works but error-prone)
router.beforeEach((to, from, next) => {
if (!isAuthenticated) next({ name: 'Login' })
else next()
})
// ✓ Modern pattern — return value controls navigation
router.beforeEach((to, from) => {
if (!isAuthenticated) return { name: 'Login' }
// returning nothing = proceed
})
router.beforeResolve — Post-Component-Load Checks
router.beforeResolve is similar to router.beforeEach because it triggers on every navigation, but resolve guards are called right before the navigation is confirmed, after all in-component guards and async route components are resolved.
// Ideal for permissions that require knowing the component is available
router.beforeResolve(async (to) => {
if (to.meta.requiresCamera) {
try {
await navigator.mediaDevices.getUserMedia({ video: true })
} catch {
return { name: 'PermissionDenied' }
}
}
})
router.afterEach — Analytics and Page Titles
afterEach hooks do not receive a next function and cannot affect the navigation — they are for side effects only.
router.afterEach((to, from, failure) => {
// Page title from meta
document.title = to.meta.title
? `${to.meta.title} — MyApp`
: 'MyApp'
// Only track successful navigations
if (!failure) {
analytics.track('page_view', {
path: to.fullPath,
name: to.name,
from: from.fullPath,
})
}
})
Per-Route Guards with beforeEnter
Per-route guards run between beforeEach and beforeRouteEnter. They can be a single function or an array of functions — arrays let you compose reusable guard logic cleanly.
// Reusable guard functions
function requireAdmin(to: RouteLocationNormalized) {
const auth = useAuthStore()
if (auth.user?.role !== 'admin') return { name: 'Forbidden' }
}
async function requireActiveSubscription(to: RouteLocationNormalized) {
const billing = useBillingStore()
await billing.fetchStatus()
if (!billing.isActive) return { name: 'Subscribe' }
}
// Apply as an array — runs in order, short-circuits on redirect
const routes = [
{
path: '/admin/billing',
component: () => import('@/pages/admin/Billing.vue'),
beforeEnter: [requireAdmin, requireActiveSubscription],
},
]
In-Component Guards (Composition API)
In-component guards use the Composition API style in Vue 3: onBeforeRouteLeave and onBeforeRouteUpdate imported from vue-router.
onBeforeRouteLeave — Unsaved Changes Warning
<script setup lang="ts">
import { ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
const isDirty = ref(false)
onBeforeRouteLeave((to, from) => {
if (isDirty.value) {
const confirmed = window.confirm(
'You have unsaved changes. Leave anyway?'
)
if (!confirmed) return false // cancel navigation
}
})
</script>
onBeforeRouteUpdate — Reacting to Param Changes
<!-- PostDetail.vue — /posts/:id -->
<script setup lang="ts">
import { ref } from 'vue'
import { onBeforeRouteUpdate, useRoute } from 'vue-router'
const route = useRoute()
const post = ref(null)
async function loadPost(id: string) {
post.value = await fetchPost(id)
}
// Initial load
await loadPost(route.params.id as string)
// When navigating from /posts/1 to /posts/2,
// the component is REUSED — beforeRouteEnter doesn't fire
// onBeforeRouteUpdate handles this correctly
onBeforeRouteUpdate(async (to) => {
await loadPost(to.params.id as string)
})
</script>
Meta Fields: Centralising Per-Route Configuration
Meta fields are one of the most underused Vue Router features. Instead of sprinkling conditions throughout guards and components, define the configuration on the route and read it in one place.
Extending RouteMeta with TypeScript
// router/types.ts — augment the RouteMeta interface
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
title?: string
requiresAuth?: boolean
guestOnly?: boolean
requiredRole?: 'admin' | 'editor' | 'user'
layout?: 'default' | 'auth' | 'blank'
transition?: string
keepAlive?: boolean
breadcrumb?: string
}
}
Defining Meta on Routes
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/pages/Home.vue'),
meta: {
title: 'Home',
layout: 'default',
transition: 'fade',
},
},
{
path: '/login',
name: 'Login',
component: () => import('@/pages/Login.vue'),
meta: {
title: 'Sign In',
guestOnly: true,
layout: 'auth',
},
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/pages/Dashboard.vue'),
meta: {
title: 'Dashboard',
requiresAuth: true,
layout: 'default',
keepAlive: true,
},
},
{
path: '/admin',
name: 'Admin',
component: () => import('@/pages/Admin.vue'),
meta: {
title: 'Admin Panel',
requiresAuth: true,
requiredRole: 'admin',
layout: 'default',
},
},
]
Reading Meta in the App
<!-- App.vue — dynamic layout from meta -->
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const layout = computed(() => {
const name = route.meta.layout ?? 'default'
return `${name}-layout`
})
</script>
<template>
<component :is="layout">
<RouterView />
</component>
</template>
<!-- RouterView with transitions from meta -->
<template>
<RouterView v-slot="{ Component, route }">
<Transition :name="route.meta.transition ?? 'fade'">
<KeepAlive :include="keepAliveRoutes">
<component :is="Component" :key="route.path" />
</KeepAlive>
</Transition>
</RouterView>
</template>
Meta Inheritance in Nested Routes
Meta fields are merged (shallowly) with parent route meta. Child routes inherit parent meta and can override individual properties.
const routes = [
{
path: '/app',
meta: { requiresAuth: true, layout: 'default' },
children: [
{
path: 'dashboard',
component: () => import('@/pages/Dashboard.vue'),
meta: { title: 'Dashboard' },
// Inherits requiresAuth: true and layout: 'default' from parent
},
{
path: 'profile',
component: () => import('@/pages/Profile.vue'),
meta: { title: 'Profile', keepAlive: false },
// Overrides keepAlive but still inherits requiresAuth
},
],
},
]
Lazy Loading: Routes and Components
Every route that isn’t needed on the initial page load should be lazy-loaded. Vite’s dynamic import splits these into separate chunks automatically.
Basic Lazy Loading
// ✗ Eager — every route is bundled into the main chunk
import Dashboard from '@/pages/Dashboard.vue'
import Settings from '@/pages/Settings.vue'
const routes = [
{ path: '/dashboard', component: Dashboard },
{ path: '/settings', component: Settings },
]
// ✓ Lazy — each route is a separate chunk, loaded on demand
const routes = [
{
path: '/dashboard',
component: () => import('@/pages/Dashboard.vue'),
},
{
path: '/settings',
component: () => import('@/pages/Settings.vue'),
},
]
Grouping Routes into Named Chunks
// Vite uses the chunk name comment for the bundle filename
// Group related routes into the same chunk
const routes = [
{
path: '/admin/users',
component: () => import(/* webpackChunkName: "admin" */ '@/pages/admin/Users.vue'),
},
{
path: '/admin/settings',
component: () => import(/* webpackChunkName: "admin" */ '@/pages/admin/Settings.vue'),
},
// Both load from the same "admin" chunk
]
Loading State for Lazy Routes
Vue Router resolves async components before confirming navigation. Add a global loading indicator to handle the wait:
// router/index.ts
const loadingBar = useLoadingBar() // from your UI library or custom
router.beforeEach(() => {
loadingBar.start()
})
router.afterEach(() => {
loadingBar.finish()
})
router.onError(() => {
loadingBar.error()
})
<!-- Or use RouterView's async component slot -->
<RouterView v-slot="{ Component }">
<Suspense>
<component :is="Component" />
<template #fallback>
<PageSkeleton />
</template>
</Suspense>
</RouterView>
Typed Routes with unplugin-vue-router
Without typed routes, route.params.id is typed as string | string[] — a useless type that requires a cast on every use. unplugin-vue-router is a build-time plugin that simplifies your routing setup and makes it safer and easier to use thanks to TypeScript. It requires Vue Router >=4.4.0.
Setup
npm install -D unplugin-vue-router
// vite.config.ts
import { defineConfig } from 'vite'
import VueRouter from 'unplugin-vue-router/vite'
import Vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
VueRouter({
routesFolder: 'src/pages', // file-based routing
dts: 'src/typed-router.d.ts', // generated type file
}),
Vue(), // ⚠️ Vue must come AFTER VueRouter
],
})
// tsconfig.json
{
"compilerOptions": {
"moduleResolution": "Bundler"
},
"include": [
"src/typed-router.d.ts"
]
}
// main.ts — use vue-router/auto instead of vue-router
import { createRouter, createWebHistory } from 'vue-router/auto'
import { routes } from 'vue-router/auto-routes'
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router
The File-Based Route Structure
src/pages/
├── index.vue # → /
├── about.vue # → /about
├── posts/
│ ├── index.vue # → /posts
│ └── [id].vue # → /posts/:id (typed!)
├── users/
│ ├── index.vue # → /users
│ └── [userId]/
│ ├── index.vue # → /users/:userId
│ └── edit.vue # → /users/:userId/edit
└── [...path].vue # → /* (404 catch-all)
Typed useRoute() — The Core Benefit
<!-- src/pages/posts/[id].vue -->
<script setup lang="ts">
import { useRoute } from 'vue-router/auto'
// route.params.id is typed as string — not string | string[]
const route = useRoute('/posts/[id]')
// TypeScript knows exactly what params are available
const postId = route.params.id // string ✓
// route.params.nonExistent // TypeScript error ✓
</script>
Typed useRouter — Catch Wrong Route Names
<script setup lang="ts">
import { useRouter } from 'vue-router/auto'
const router = useRouter()
// TypeScript catches wrong route names at compile time
router.push({ name: '/posts/[id]', params: { id: '123' } })
// TypeScript error — wrong param name
router.push({ name: '/posts/[id]', params: { postId: '123' } }) // ✗
// TypeScript error — nonexistent route name
router.push({ name: '/nonexistent' }) // ✗
definePage — Per-File Route Meta and Config
The definePage macro lets you define route-level configuration directly inside a page component, rather than in the central router config. This keeps the meta co-located with the component that uses it.
<!-- src/pages/dashboard.vue -->
<script setup lang="ts">
// definePage is a macro — no import needed with unplugin-vue-router
definePage({
name: 'Dashboard',
meta: {
title: 'Dashboard',
requiresAuth: true,
layout: 'default',
keepAlive: true,
},
})
</script>
<!-- src/pages/posts/[id].vue -->
<script setup lang="ts">
definePage({
name: 'PostDetail',
meta: {
title: 'Post',
breadcrumb: 'Post Detail',
},
})
const route = useRoute('PostDetail')
const post = await fetchPost(route.params.id)
</script>
Dynamic Routes and Params
Required and Optional Params
const routes = [
// Required param — /users/42
{ path: '/users/:id', component: UserDetail },
// Optional param — /posts or /posts/latest
{ path: '/posts/:sort?', component: PostList },
// Repeated param — /files/docs/images/photo.jpg
{ path: '/files/:path+', component: FileBrowser },
// Zero or more — /tags or /tags/vue/typescript
{ path: '/tags/:tags*', component: TagList },
]
Custom Param Parsers (Vue Router 4.4+)
const routes = [
{
path: '/items/:id',
component: ItemDetail,
// Automatically parse and serialize the param
props: true,
// Custom parser — ensures id is always a number
params: {
id: {
parse: (value: string) => parseInt(value, 10),
stringify: (value: number) => `${value}`,
},
},
},
]
Scroll Behavior
One of the most important — and most neglected — Vue Router features. Without it, your SPA doesn’t scroll to the top on navigation and doesn’t restore scroll position on back/forward.
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
// Browser back/forward — restore scroll position
if (savedPosition) {
return savedPosition
}
// Anchor links — scroll to the element
if (to.hash) {
return {
el: to.hash,
behavior: 'smooth',
top: 80, // offset for fixed header
}
}
// New navigation — scroll to top
return { top: 0, behavior: 'smooth' }
},
})
Delayed Scroll (After Transition)
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
}
// Wait for page transition to complete before scrolling
return new Promise((resolve) => {
setTimeout(() => {
resolve({ top: 0, behavior: 'smooth' })
}, 300) // match your transition duration
})
}
Route Params as Props
Passing route params as component props decouples your components from the router — making them easier to test and reuse.
const routes = [
{
path: '/posts/:id',
component: PostDetail,
props: true, // passes params as props
},
{
path: '/search',
component: SearchResults,
props: (route) => ({ // transform query into props
query: route.query.q,
page: Number(route.query.page) || 1,
category: route.query.category ?? 'all',
}),
},
]
<!-- PostDetail.vue — receives id as a prop, not from useRoute() -->
<script setup lang="ts">
defineProps<{ id: string }>()
// No useRoute() needed — the component has no router dependency
const post = await fetchPost(props.id)
</script>
Nested Routes and Named Views
Nested Routes
const routes = [
{
path: '/settings',
component: () => import('@/layouts/Settings.vue'),
children: [
{
path: '', // matches /settings exactly
component: () => import('@/pages/settings/General.vue'),
name: 'SettingsGeneral',
},
{
path: 'account', // matches /settings/account
component: () => import('@/pages/settings/Account.vue'),
name: 'SettingsAccount',
},
{
path: 'billing',
component: () => import('@/pages/settings/Billing.vue'),
name: 'SettingsBilling',
meta: { requiredRole: 'admin' },
},
],
},
]
Named Views — Multiple Outlets on One Route
// Display sidebar and main content from different components on one route
const routes = [
{
path: '/dashboard',
components: {
default: () => import('@/pages/Dashboard.vue'),
sidebar: () => import('@/components/DashboardSidebar.vue'),
},
},
]
<!-- App.vue — two router-view outlets -->
<template>
<div class="layout">
<aside>
<RouterView name="sidebar" />
</aside>
<main>
<RouterView /> <!-- default outlet -->
</main>
</div>
</template>
Navigation Failures
Vue Router 4 returns a navigation failure when a guard cancels, redirects, or an error occurs. You can inspect the result to handle these cases explicitly.
import { isNavigationFailure, NavigationFailureType } from 'vue-router'
const failure = await router.push({ name: 'Dashboard' })
if (isNavigationFailure(failure, NavigationFailureType.cancelled)) {
// A guard cancelled the navigation
showToast('Navigation was blocked.')
}
if (isNavigationFailure(failure, NavigationFailureType.redirected)) {
// A guard redirected — failure.to has the redirect destination
console.log('Redirected to:', failure.to.fullPath)
}
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
// Already on this route — can ignore or refresh
}
A Production Router Setup
Putting it all together — a complete, typed router configuration for a real application:
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router/auto'
import { routes } from 'vue-router/auto-routes'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition
if (to.hash) return { el: to.hash, behavior: 'smooth', top: 80 }
return new Promise(resolve => setTimeout(() => resolve({ top: 0 }), 200))
},
})
// ── Guards ─────────────────────────────────────────────────────────────────
router.beforeEach(async (to, from) => {
const auth = useAuthStore()
// Initialise auth state on first load
if (!auth.initialised) {
await auth.init()
}
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return { name: '/login', query: { redirect: to.fullPath } }
}
if (to.meta.guestOnly && auth.isAuthenticated) {
return { name: '/' }
}
if (to.meta.requiredRole && auth.user?.role !== to.meta.requiredRole) {
return { name: '/403' }
}
})
router.beforeResolve(async (to) => {
if (to.meta.requiresCamera) {
try {
await navigator.mediaDevices.getUserMedia({ video: true })
} catch {
return { name: '/permission-denied' }
}
}
})
router.afterEach((to, from, failure) => {
// Update page title
document.title = to.meta.title ? `${to.meta.title} — MyApp` : 'MyApp'
// Analytics — only on successful navigations
if (!failure && to.name !== from.name) {
analytics.page({ name: String(to.name), path: to.fullPath })
}
})
router.onError((error, to) => {
// Handle lazy-loaded chunk failures (network error, deploy update)
if (error.message.includes('Failed to fetch dynamically imported module')) {
window.location.href = to.fullPath
}
})
export default router
TypeScript: Augmenting RouteMeta
This file should live in your project and be included in tsconfig.json:
// router/types.ts
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
// Page metadata
title?: string
description?: string
breadcrumb?: string
// Access control
requiresAuth?: boolean
guestOnly?: boolean
requiredRole?: 'admin' | 'editor' | 'user'
// Layout and UI
layout?: 'default' | 'auth' | 'blank' | 'admin'
transition?: 'fade' | 'slide-left' | 'slide-right'
keepAlive?: boolean
// Feature flags
requiresCamera?: boolean
requiresBeta?: boolean
}
}
export {}
Final Thoughts
Vue Router 4 is a much deeper tool than its push()/go() surface implies. Navigation guards give you a structured, declarative way to protect routes without scattering auth logic through components. Meta fields let you centralise every piece of per-route configuration — titles, layouts, access rules, transitions — in one place and read it anywhere. Lazy loading cuts your initial bundle meaningfully when applied consistently. And typed routes with unplugin-vue-router (now part of Vue Router 5 core) finally make route.params as safe and useful as any other TypeScript interface.
The patterns in this post — the auth guard with redirect-back, the TypeScript RouteMeta augmentation, the definePage macro for co-located config, the scroll behaviour that handles hash anchors and back/forward, the onBeforeRouteUpdate pattern for param-change detection — these are the patterns that production Vue applications actually use. They are all built on APIs that ship with Vue Router. There’s nothing extra to install except unplugin-vue-router for typed routes.
Build the router once, build it right, and the rest of the application becomes dramatically easier to reason about.
