Vue Router 4: Route Matching Algorithm and Async Navigation Guard Pipeline
Vue Router 4's route matching is not simple string comparison โ it's a regex engine with a scoring system. Each route rule is compiled into a regular expression, and every navigation passes through a 15-step async pipeline. Any step that calls next(false) or throws an exception aborts the navigation. Understanding this pipeline reveals why some guards "don't work," and how to correctly implement dynamic permission-based routing.
Level 1 ยท What You Need to Know (1โ3 Years Experience)
Vue Router 4's Three History Modes
HTML5 History API (createWebHistory):
// URL: https://example.com/user/profile
// Requires server config: all paths return index.html
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [...]
})
Nginx server config:
location / {
try_files $uri $uri/ /index.html;
}
Hash mode (createWebHashHistory):
// URL: https://example.com/#/user/profile
// No server config needed (hash never reaches the server)
const router = createRouter({
history: createWebHashHistory(),
routes: [...]
})
Memory mode (createMemoryHistory):
// URL stays unchanged โ for SSR and unit tests
const router = createRouter({
history: createMemoryHistory(),
routes: [...]
})
Route Definitions: Dynamic Parameters and Nested Routes
const routes = [
// Static route
{ path: '/', component: Home },
// Dynamic parameter
{ path: '/user/:id', component: User, props: true },
// Optional parameter (Vue Router 4 syntax)
{ path: '/user/:id?', component: User },
// Wildcard (must go last)
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound },
// Nested routes
{
path: '/admin',
component: AdminLayout,
children: [
{ path: '', component: AdminDashboard }, // /admin
{ path: 'users', component: AdminUsers }, // /admin/users
{ path: 'users/:id', component: AdminUserEdit }, // /admin/users/123
]
}
]
Navigation Guards at Three Levels
// Global guards
router.beforeEach((to, from, next) => {
if (!isAuthenticated && to.meta.requiresAuth) {
next('/login')
} else {
next()
}
})
router.afterEach((to, from) => {
document.title = to.meta.title as string || 'My App'
})
// Per-route guard
const routes = [
{
path: '/admin',
component: Admin,
beforeEnter: (to, from) => {
if (!isAdmin()) return '/403'
}
}
]
// In-component guards
const MyComponent = {
beforeRouteEnter(to, from, next) {
// Component instance doesn't exist yet โ cannot use this
next(vm => {
// vm is the component instance
vm.loadData(to.params.id)
})
},
beforeRouteLeave(to, from) {
if (hasUnsavedChanges) {
return false // block navigation
}
}
}
Dynamic Routes: RBAC Permission Control
// Add routes dynamically after login based on role
async function setupRoutes(userRole: string) {
const permittedRoutes = await fetchRoutesByRole(userRole)
permittedRoutes.forEach(route => {
router.addRoute(route)
})
// Critical: after addRoute, re-navigate so current path matches new routes
router.push(router.currentRoute.value.fullPath)
}
// Remove a route
router.removeRoute('admin-route-name')
Route Lazy Loading
// Vite automatically code-splits each dynamic import
const routes = [
{
path: '/user',
component: () => import('./views/User.vue')
},
{
path: '/admin',
component: () => import('./views/Admin.vue')
}
]
Level 2 ยท How It Actually Works (3โ5 Years Experience)
Route Matching Algorithm: Regex Compilation
Each route is compiled into a regular expression when registered:
'/user/:id' โ /^\/user\/([^\/]+?)\/?$/i
'/:pathMatch(.*)*' โ /^(?:\/(.*))?\/?$/i
Parameter extraction:
Route: /user/:id/posts/:postId
URL: /user/42/posts/100
Regex: /^\/user\/([^\/]+?)\/posts\/([^\/]+?)\/?$/i
Groups: [42, 100]
Result: { id: '42', postId: '100' }
Route Ranking Score System
When multiple routes match the same URL, Vue Router uses a scoring system to determine priority:
Scoring dimensions (each path segment scored independently):
Static segment: highest score (e.g., /user)
โ score: 40
Dynamic parameter: medium score (e.g., /:id)
โ score: 20
Optional parameter: lower score (e.g., /:id?)
โ score: 10
Wildcard: lowest score (e.g., /:pathMatch(.*)*)
โ score: 1
Example scenario:
Route A: /user/profile (static path)
Route B: /user/:id (dynamic parameter)
URL: /user/profile
Route A score: static(40) + static(40) = 80
Route B score: static(40) + dynamic(20) = 60
Route A wins (80 > 60)
Routes with more segments win over routes with fewer segments when scores are otherwise equal:
Route A: /user/:id โ score: 60
Route B: /user/:id/posts โ score: 80
URL: /user/42/posts
Route B wins (more specific match)
Complete Navigation Guard Execution Order (15 Steps)
Navigation triggered (router.push('/new-path'))
โ
โผ
1. In-component guard: beforeRouteLeave (leaving component)
โ
โผ
2. Global guard: router.beforeEach
โ
โผ
3. Route reuse detection:
โโ If component is reused (same route, different params):
4. In-component guard: beforeRouteUpdate
โ
โผ
5. Per-route guard: beforeEnter
โ
โผ
6. Resolve async route components (trigger lazy loading)
โ
โผ
7. In-component guard: beforeRouteEnter (new component, no `this`)
โ
โผ
8. Global guard: router.beforeResolve
โ
โผ
--- Navigation confirmed ---
โ
โผ
9. Global guard: router.afterEach (no next, cannot affect navigation)
โ
โผ
10. DOM updates (component renders)
โ
โผ
11. beforeRouteEnter's next(vm => ...) callbacks execute
(component instance now available)
Key rule: Steps 1โ8 are each asynchronous โ the next step only begins after the previous one resolves (serial execution). Any step returning false or a navigation target aborts the current navigation.
Async Guard Pipeline Mechanism
// Internal implementation (simplified runGuardQueue)
async function runGuardQueue(guards: NavigationGuard[]) {
for (const guard of guards) {
await new Promise<void>((resolve, reject) => {
const next = (value?: unknown) => {
if (value === false) {
reject(new NavigationFailure(NavigationFailureType.aborted))
} else if (isRouteLocation(value)) {
reject(value) // redirect
} else {
resolve()
}
}
const result = guard.call(null, to, from, next)
// Support return value syntax (no explicit next)
if (result !== undefined) {
if (result === false) {
reject(new NavigationFailure(NavigationFailureType.aborted))
} else if (isRouteLocation(result)) {
reject(result) // redirect
} else if (result === true || result === undefined) {
resolve()
} else if (result instanceof Promise) {
result.then(resolve, reject)
}
}
})
}
}
Vue Router 4 guards support two syntaxes:
- Call
next()(legacy compatible) - Return value (recommended):
return false(abort),return '/redirect'(redirect),return undefined(continue)
Guard Pipeline Flow Diagram
beforeEach guard list: [guard1, guard2, guard3]
Serial execution:
guard1 called
โ
โโ next() / return undefined
โ
โผ
guard2 called
โ
โโ next('/login') / return '/login'
โ
โผ
Current navigation aborted, new navigation to '/login' triggered
guard3 never executes
NavigationFailure: Types of Navigation Failures
import { isNavigationFailure, NavigationFailureType } from 'vue-router'
router.push('/user/123').then(failure => {
if (isNavigationFailure(failure, NavigationFailureType.aborted)) {
// Guard returned false
}
if (isNavigationFailure(failure, NavigationFailureType.cancelled)) {
// New navigation triggered before previous one completed (race condition)
}
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
// Navigating to the current route (duplicate navigation)
}
})
Level 3 ยท Design Documents and Source Code (Senior Developers)
Route Record Internal Data Structure
// packages/router/src/types/index.ts
interface RouteRecordNormalized {
path: string
redirect?: RouteRecordRedirectOption
name?: RouteRecordName
components?: Record<string, RawRouteComponent>
children: RouteRecordRaw[]
meta: RouteMeta
props: Record<string, _RouteProps>
aliasOf?: RouteRecordNormalized
beforeEnter?: NavigationGuard | NavigationGuard[]
// For matching
regex: RegExp // compiled regex
score: number[][] // score array per segment
keys: PathParserParamKey[] // parameter key list
}
Path Parser Compilation
// packages/router/src/matcher/pathParserRanker.ts
export function tokenizePath(path: string): Token[][] {
// Split path string into token arrays
// '/user/:id/posts' โ [
// [{ type: 'Static', value: 'user' }],
// [{ type: 'Param', value: 'id' }],
// [{ type: 'Static', value: 'posts' }],
// ]
}
export function tokensToParser(segments: Token[][]): PathParser {
let pattern = '^'
const keys: PathParserParamKey[] = []
const score: number[][] = []
for (const segment of segments) {
const segmentScore: number[] = []
for (const token of segment) {
if (token.type === TokenType.Static) {
pattern += '/' + escapeRegex(token.value)
segmentScore.push(PathScore.Static) // 40 points
} else if (token.type === TokenType.Param) {
const { value, repeatable, optional } = token
keys.push({ name: value, repeatable, optional })
pattern += '/' + (optional ? '?' : '') + `([^/]+?)`
segmentScore.push(
optional ? PathScore.OptionalParam : PathScore.DynamicParam
) // 10 or 20 points
}
}
score.push(segmentScore)
}
return { re: new RegExp(pattern), score, keys }
}
Navigation Guard Dispatch Implementation
// packages/router/src/router.ts (simplified navigate function)
async function navigate(to, from) {
const guards: Lazy<any>[] = []
// 1. Collect beforeRouteLeave guards from leaving components
extractComponentsGuards(leavingRecords, 'beforeRouteLeave', to, from)
// 2. Global beforeEach guards
guards.push(...router.beforeEachGuards)
// 3. beforeRouteUpdate for reused components
extractComponentsGuards(updatingRecords, 'beforeRouteUpdate', to, from)
// 4. Per-route beforeEnter
enteringRecords.forEach(record => {
if (record.beforeEnter) guards.push(...toArray(record.beforeEnter))
})
// Run guards serially
await runGuardQueue(guards)
// 5. Resolve components (trigger lazy loading)
await Promise.all(
enteringRecords.map(async record => {
if (!record.components) return
await Promise.all(
Object.values(record.components).map(component => {
if (isRouteComponent(component)) return
return component() // execute () => import(...)
})
)
})
)
// 6. beforeRouteEnter for entering components
extractComponentsGuards(enteringRecords, 'beforeRouteEnter', to, from)
// 7. Global beforeResolve
guards.push(...router.beforeResolveGuards)
await runGuardQueue(guards)
// All guards passed โ confirm navigation
}
Scroll Behavior Implementation
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
// Restore scroll position when going back
if (savedPosition) {
return savedPosition
}
// Scroll to anchor if hash present
if (to.hash) {
return { el: to.hash, behavior: 'smooth' }
}
// Default: scroll to top
return { top: 0 }
}
})
savedPosition is provided by the browser's popstate event. Vue Router records the current scroll position on popstate, then passes it to scrollBehavior on history restoration.
Lazy Loading and Vite Code Splitting
// Vite compiles each dynamic import into a separate chunk
const User = () => import('./views/User.vue')
// Compiled to: __vite_dynamic_import('./views/User.vue')
// Produces: /assets/User-[hash].js
// Global loading indicator for lazy routes
router.beforeEach(() => { showLoading() })
router.afterEach(() => { hideLoading() })
Level 4 ยท Edge Cases and Pitfalls (All Levels)
Pitfall 1: this is Unavailable in beforeRouteEnter
// โ Wrong: component instance doesn't exist yet
beforeRouteEnter(to, from, next) {
console.log(this) // undefined (strict mode)
this.loadData() // TypeError!
next()
}
// โ
Correct: access instance via next callback
beforeRouteEnter(to, from, next) {
next(vm => {
// Called after component mounts
vm.loadData(to.params.id)
})
}
// โ
In <script setup>, use onBeforeRouteEnter
import { onBeforeRouteEnter } from 'vue-router'
onBeforeRouteEnter((to, from, next) => {
next(vm => {
// vm is the component instance
})
})
Pitfall 2: addRoute Doesn't Refresh Current Route Matching
// โ addRoute won't trigger re-matching of the current route
router.addRoute({ path: '/new', component: NewPage })
// If currently at /new, nothing visually changes
// โ
Must manually re-navigate
router.addRoute({ path: '/new', component: NewPage })
if (router.currentRoute.value.path === '/new') {
router.replace('/new')
}
// Or universally:
router.push(router.currentRoute.value.fullPath)
Real scenario: After login, dynamic permission routes are added. Without re-navigating after addRoute, the current page stays on the old route even though a matching new route now exists.
Pitfall 3: Forgetting to Handle Infinite Redirects in beforeEach
// โ Can cause infinite redirect loop
router.beforeEach((to, from) => {
if (!isAuthenticated) {
return '/login' // If already on /login, this triggers again โ infinite loop!
}
})
// โ
Correct: check current route to avoid loop
router.beforeEach((to, from) => {
if (!isAuthenticated && to.name !== 'Login') {
return { name: 'Login' }
}
// Or use meta flags
if (!isAuthenticated && to.meta.requiresAuth) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
})
Pitfall 4: Wildcard Route Order Matters in Static Route Arrays
// โ Wildcard before specific routes captures everything
const routes = [
{ path: '/:pathMatch(.*)*', component: NotFound }, // first
{ path: '/user/:id', component: User }, // never reached!
]
// โ
Correct: wildcard goes last
const routes = [
{ path: '/user/:id', component: User },
{ path: '/:pathMatch(.*)*', component: NotFound }, // last
]
Routes added via router.addRoute() are automatically ranked. But in a static routes array, order within same-score routes matters โ wildcards must go last.
Pitfall 5: router.push Promise Doesn't Reject on Navigation Failure
In Vue Router 4, navigation failures (blocked by guards, duplicate navigation) don't reject the Promise โ they resolve with a NavigationFailure:
// โ Wrong error handling
try {
await router.push('/protected')
} catch (e) {
// Guard blocking navigation does NOT reach here!
}
// โ
Correct handling
const failure = await router.push('/protected')
if (failure) {
if (isNavigationFailure(failure, NavigationFailureType.aborted)) {
console.log('Navigation was blocked by a guard')
}
}
Only programming errors (like passing an invalid route object) cause Promise rejection.
Pitfall 6: keep-alive Interaction with Navigation Guards
Components cached by <keep-alive> don't trigger beforeRouteEnter when reactivated โ they trigger the activated lifecycle hook instead:
// โ Relying on beforeRouteEnter for data loading fails with keep-alive
beforeRouteEnter(to, from, next) {
next(vm => vm.loadData()) // won't fire on reactivation after caching
}
// โ
Handle both cases
setup() {
onActivated(() => {
loadData() // fires when keep-alive reactivates
})
onBeforeRouteEnter((to, from, next) => {
next(vm => vm.loadData()) // fires on first entry
})
}
Chapter Summary
-
Route matching is a scored regex engine: Each route rule is compiled into a regex and score array at registration time. When multiple routes match, they're ranked by score โ static segments beat dynamic segments, more specific paths beat wildcards. This scoring system is a major Vue Router 4 improvement over v3, eliminating the dependence on route definition order.
-
Navigation guards form an async serial pipeline: 15 steps execute serially โ each step asynchronously resolves before the next begins. From
beforeRouteLeavethroughbeforeResolve, every step can abort, redirect, or allow the navigation;afterEachfires after confirmation and cannot change the outcome. -
Three history modes serve different needs:
createWebHistoryproduces clean URLs but requires server configuration;createWebHashHistoryneeds no server setup;createMemoryHistorysuits SSR and testing environments. -
addRoutedoesn't trigger re-matching of the current route: After dynamically adding routes, you must explicitly callrouter.push()orrouter.replace()to match the current path against newly added routes. This is the most commonly missed step in RBAC dynamic permission routing. -
beforeRouteEnteris the only guard wherethisis unavailable: Because the component instance doesn't exist yet whenbeforeRouteEnterfires, component access requires thenext(vm => ...)callback. In Vue Router 4's Composition API,onBeforeRouteEnterwith anextcallback is the standard solution.