Route Guards
Route guards are Uni Router's core capability, allowing you to insert custom logic during navigation: authentication, logging, data preloading, leave confirmation, etc. This chapter dives deep into the guard execution mechanism and the return-value pattern (recommended).
Guard Overview
Uni Router provides five types of guards. Forward navigation execution order:
Navigation triggered
│
├─ 1. beforeEach Global pre guard (multiple allowed)
│ └─ Can abort / redirect / pass
│
├─ 2. beforeEnter Route-specific guard (configured in RouteConfig)
│ └─ Can abort / redirect / pass
│
├─ 3. beforeResolve Global resolve guard (multiple allowed)
│ └─ Can abort / redirect / pass
│
├─ 4. uni navigation API call navigateTo / redirectTo / ...
│
└─ 5. afterEach Global post hook (multiple allowed)
└─ Observation only, cannot change navigation resultBack operations (physical back button / browser back / router.back()) first run the onBeforeBack back guard, then reuse the beforeEach → beforeResolve → afterEach chain. See Back Guard.
Guard Purposes
| Guard | Registration | Typical Scenarios |
|---|---|---|
beforeEach | router.beforeEach(fn) | Auth check, permission check, global logging |
beforeEnter | RouteConfig.beforeEnter | Route-specific validation (like reading specific data) |
beforeResolve | router.beforeResolve(fn) | Final confirmation after data preload completes |
afterEach | router.afterEach(fn) | Set title, analytics, cleanup state, receive failure info |
onBeforeBack | router.onBeforeBack(fn) | Back interception, leave confirmation (physical back / browser back / router.back()) |
beforeResolve's Purpose
beforeResolve executes after beforeEnter, when all pre-validation has passed. Suitable for "after all guards agree" final logic, like confirming data is fully loaded. Its difference from beforeEach is only in execution timing.
Registering Guards
Global Guards
const router = createRouter({ routes })
// Pre guard (return-value pattern, recommended)
const removeBefore = router.beforeEach((to, from) => {
if (to.meta.requireAuth && !isLoggedIn()) {
return { name: 'login' } // redirect
}
// Return undefined or true to proceed
})
// Resolve guard (return-value pattern)
router.beforeResolve(async (to) => {
// After all pre guards pass, preload data
if (to.name === 'detail') {
await store.fetchDetail(to.query.id)
}
// No return value = proceed
})
// Post hook (receives failure parameter)
router.afterEach((to, from, failure) => {
if (failure) {
console.error('Navigation failed:', failure.message)
return
}
if (to.meta.title) {
uni.setNavigationBarTitle({ title: to.meta.title as string })
}
})
// Remove guard
removeBefore()Route-Specific Guards
const routes = [
{
path: 'pages/admin/admin',
name: 'admin',
meta: { requireAdmin: true },
beforeEnter: (to, from) => {
if (user.role === 'admin') return true // proceed
return { name: '403' } // redirect
}
},
{
path: 'pages/edit/edit',
name: 'edit',
// Supports array form
beforeEnter: [
checkAuth,
checkPermission,
checkLockStatus
]
}
]Guard Arrays
beforeEnter supports passing an array, executing in order. If any guard aborts or redirects, subsequent guards won't execute.
Guard Return Values
The return-value pattern is the recommended guard style since v2.1.0. Guards control navigation behavior through return values, no need to call next() callback.
1. Pass: return undefined / return true
router.beforeEach((to, from) => {
return true // Pass, continue to next guard
})
// No return also means pass
router.beforeEach((to, from) => {
// Default: pass
})2. Abort: return false
router.beforeEach((to, from) => {
if (isOffline()) {
uni.showToast({ title: 'Network unavailable', icon: 'none' })
return false // Abort navigation, stay on current page
}
})Abort throws NavigationFailure (NAVIGATION_ABORTED).
3. Redirect: return RouteLocationRaw
router.beforeEach((to, from) => {
if (to.meta.requireAuth && !isLoggedIn()) {
// Redirect to login page, carry original target for post-login return
return { name: 'login', query: { redirect: to.fullPath } }
}
})Redirects re-trigger the complete guard chain (starting from beforeEach) and increment the redirect depth counter.
4. Throw Error to Abort
router.beforeEach((to, from) => {
if (to.meta.requireAuth) {
throw new Error('Permission denied') // Cancel navigation (NAVIGATION_CANCELLED)
}
})
// Or return an Error object
router.beforeEach((to, from) => {
if (to.meta.requireAuth) {
return new Error('Permission denied') // Cancel navigation (NAVIGATION_CANCELLED)
}
})Return Value Summary
| Return Value | Behavior |
|---|---|
undefined / void / true | Pass, continue to next guard |
false | Abort navigation (NAVIGATION_ABORTED) |
string (e.g. '/login') | Redirect to path |
RouteLocationRaw (e.g. { name: 'login' }) | Redirect to route location |
NavigationRedirect (e.g. { location, mode }) | Redirect to route location and specify navigation mode |
Error object | Cancel navigation (NAVIGATION_CANCELLED) |
| Thrown exception | Cancel navigation (NAVIGATION_CANCELLED) |
Controllable Redirect
By default, guard redirects use the original navigation method that triggered the guard (a redirect from a push-triggered guard still uses uni.navigateTo). By returning a { location, mode } object, you can explicitly specify the navigation method used for the redirect.
router.beforeEach((to, from) => {
if (to.meta.requireAuth && !isLoggedIn()) {
// Use replace to go to login page, avoiding the login page staying in the page stack
return { location: { name: 'login', query: { redirect: to.fullPath } }, mode: 'replace' }
}
})Redirect Method Priority
Explicit mode (NavigationRedirect.mode) > original navigation method > back falls back to relaunch| Triggering Navigation | Guard Return | Actual Redirect Method |
|---|---|---|
push | { location, mode: 'replace' } | replace (explicit) |
push | { name: 'login' } | push (original) |
replace | { location, mode: 'relaunch' } | relaunch (explicit) |
replace | { name: 'login' } | replace (original) |
back | { location, mode: 'push' } | push (explicit) |
back | { name: 'login' } | relaunch (back cannot navigate outside stack, falls back) |
mode Options
type NavigationRedirectMode = 'push' | 'replace' | 'relaunch'| mode | uni API | Use Case |
|---|---|---|
'push' | navigateTo | Need to return to original page after login, keep target page in stack |
'replace' | redirectTo | Replace current page, no history (e.g. login page) |
'relaunch' | reLaunch | Clear stack (e.g. return home on insufficient permissions) |
Practice: Login Redirect
router.beforeEach((to, from) => {
if (to.meta.requireAuth && !isLoggedIn()) {
if (from.name === 'login') {
// Already on login page without permissions, use replace to avoid stack buildup
return false
}
// Use replace to go to login page, avoiding the login page staying in the page stack
return { location: { name: 'login', query: { redirect: to.fullPath } }, mode: 'replace' }
}
})
// After login success
async function onLoginSuccess(redirect: string) {
// Use replace to return to original page, avoiding login page staying in stack
await router.replace(redirect)
}Practice: Clear Stack on Insufficient Permissions
router.beforeEach((to, from) => {
if (to.meta.roles && !hasRole(to.meta.roles)) {
// Insufficient permissions, clear stack and return home
return { location: { name: 'home' }, mode: 'relaunch' }
}
})Async Guards
Guards support async functions and returning Promises:
router.beforeEach(async (to, from) => {
// Async validate token validity
const valid = await checkToken()
if (!valid) {
return { name: 'login' } // Redirect to login page
}
// Pass
})Promise Reject Aborts Navigation
router.beforeEach(async (to, from) => {
try {
await fetchUserProfile()
// Pass
} catch (err) {
// reject will abort navigation (NAVIGATION_CANCELLED)
throw err
}
})Return Value vs Exception
return false→NAVIGATION_ABORTED(user actively aborts)throw/reject→NAVIGATION_CANCELLED(exception causes cancellation)
Recommend using return false for "active abort" and exceptions for "unexpected errors".
Timeout Protection
Guards may get stuck due to async operations (like network requests not responding). Uni Router provides timeout protection:
const router = createRouter({
routes,
guardTimeout: 10000 // Default 10 seconds
})Guard execution
→ Doesn't return a result or throw within 10 seconds
→ Outputs warning: "Navigation guard did not resolve within 10s"
→ Auto-aborts navigation (NAVIGATION_CANCELLED)Adjust Timeout
Increase timeout when guards have time-consuming requests:
const router = createRouter({
routes,
guardTimeout: 30000 // 30 seconds
})Set to 0 to disable timeout protection (not recommended, may cause navigation to hang permanently).
Guard Execution Details
Execution Order
Multiple guards of the same type execute in registration order:
router.beforeEach(guard1) // Executes first
router.beforeEach(guard2) // Executes second
router.beforeEach(guard3) // Executes lastguard1 → guard2 → guard3 → beforeEnter → beforeResolve1 → beforeResolve2 → APIShort-Circuit Effect of Abort/Redirect
If any guard aborts or redirects, subsequent guards won't execute:
router.beforeEach((to, from) => {
return false // Abort
})
router.beforeEach((to, from) => {
console.log('Will not execute')
})Redirect Re-triggers Guard Chain
router.beforeEach((to, from) => {
if (to.name === 'a') {
return { name: 'b' } // Redirect to b
}
})
router.beforeEach((to, from) => {
// When redirecting to b, this guard executes again
console.log(to.name) // 'b'
})push(a) → beforeEach[1] redirects to b
→ beforeEach[1] executes again (to=b) → pass
→ beforeEach[2] executes (to=b) → pass
→ ... → navigateTo(b)Avoid Infinite Redirects
Redirect depth limit is 10. A→B→A→B... loop will throw NAVIGATION_CANCELLED after the 10th time. Be sure to add termination conditions in redirect logic.
afterEach Post Hooks
afterEach executes after navigation completes and cannot change the navigation result (doesn't accept next parameter), but receives a third failure parameter for navigation failure info:
router.afterEach((to, from, failure) => {
if (failure) {
// Log error on navigation failure
console.error('Navigation failed:', failure.message)
return
}
// Set page title
if (to.meta.title) {
uni.setNavigationBarTitle({ title: to.meta.title as string })
}
// Analytics
trackPageView(to.path, from.path)
})Scenarios Where afterEach Doesn't Trigger
State Sync Doesn't Trigger afterEach
afterEach only triggers after complete navigation (through pre guards) completes. The following scenarios don't trigger afterEach:
- State sync from
syncRoute()/syncCurrentRoute()
Physical back button and browser back go through the back guard chain, and afterEach triggers normally after the guards pass (see Back Guard). To listen for all route changes (including state sync), use onRouteChange.
router.onRouteChange((to, from) => {
// Both complete navigation and state sync trigger
if (to._synced) {
console.log('State sync (non-complete navigation)')
}
})Practice Patterns
Pattern 1: Auth Check
// Global pre guard
router.beforeEach((to, from) => {
const isLoggedIn = !!uni.getStorageSync('token')
if (to.meta.requireAuth && !isLoggedIn) {
// Not logged in → go to login page, use replace to avoid login page staying in stack
return { location: { name: 'login', query: { redirect: to.fullPath } }, mode: 'replace' }
}
if (to.name === 'login' && isLoggedIn) {
// Already logged in accessing login page → go to home
return { location: { name: 'home' }, mode: 'replace' }
}
// Pass
})Pattern 2: Permission Control
// Extend RouteMeta
declare module '@meng-xi/uni-router' {
interface RouteMeta {
roles?: string[]
}
}
router.beforeEach((to, from) => {
const userRoles = getUserRoles()
if (to.meta.roles && !to.meta.roles.some(r => userRoles.includes(r))) {
// Insufficient permissions → clear stack and return home
return { location: { name: 'home' }, mode: 'relaunch' }
}
})Pattern 3: Leave Confirmation
// Mark page as "dirty" state
const routes = [
{
path: 'pages/edit/edit',
name: 'edit',
meta: { dirty: false } // Dynamically modified at runtime
}
]
router.beforeEach((to, from) => {
if (from.meta.dirty) {
// Leave confirmation needs async dialog, wrap with Promise
return new Promise((resolve) => {
uni.showModal({
title: 'Notice',
content: 'You have unsaved changes. Leave anyway?',
success: (res) => {
if (res.confirm) {
from.meta.dirty = false // Reset
resolve(true) // Pass
} else {
resolve(false) // Abort
}
}
})
})
}
})Pattern 4: Data Preloading
// Preload in beforeResolve (all pre-validation has passed)
router.beforeResolve(async (to) => {
try {
switch (to.name) {
case 'detail':
await store.fetchDetail(to.query.id)
break
case 'list':
await store.fetchList(to.queryInt('page', 1))
break
}
// Pass
} catch (err) {
uni.showToast({ title: 'Load failed', icon: 'none' })
return false // Data load failed, abort navigation
}
})Pattern 5: Auto Page Title Setting
router.afterEach((to) => {
const title = to.meta.title as string | undefined
if (title) {
uni.setNavigationBarTitle({ title })
} else {
uni.setNavigationBarTitle({ title: 'Default Title' })
}
})Pattern 6: Route-Specific Validation
const routes = [
{
path: 'pages/order/order',
name: 'order',
beforeEnter: [
// Must select address first
(to, from) => {
if (!store.selectedAddress) {
uni.showToast({ title: 'Please select an address first', icon: 'none' })
return false
}
},
// Must have products
(to, from) => {
if (store.cart.length === 0) {
return { name: 'cart' }
}
}
]
}
]Back Guard onBeforeBack
onBeforeBack is a global back guard that runs when a back operation is triggered, used for leave confirmation, back interception, etc.
// Register a back guard (return false to block back, true / undefined to allow)
router.onBeforeBack((to, from) => {
if (hasUnsavedChanges) {
uni.showToast({ title: 'Unsaved changes', icon: 'none' })
return false // Block back
}
// return undefined or true to proceed
})
// Remove the guard
const remove = router.onBeforeBack(guard)
remove()Back Guard Chain
Back operations share the guard chain with forward navigation:
Back triggered
→ 1. onBeforeBack Back guard (multiple allowed)
→ 2. beforeEach Global pre guard
→ 3. beforeResolve Global resolve guard
→ 4. uni.navigateBack (executed after guards pass)
→ 5. afterEach Post hook (triggers on both success and block)onBeforeBack returns false to block back; true / undefined to allow; supports async (Promise).
Platform Support
| Back scenario | App | H5 | Mini-program |
|---|---|---|---|
Physical back / navigation-bar back / uni.navigateBack | ✅ via onBackPress | — | ❌ |
| Browser back button / back gesture | — | ✅ via popstate | — |
| iOS edge swipe back | ⚠️ requires setSideSlipGesture('none') | — | — |
router.back() / programmatic uni.navigateBack | ✅ | ✅ | ✅ (requires InterceptorPlugin) |
Mini-program native back cannot be intercepted
Mini-program top-left/top-right back, physical back, and swipe back are controlled by the host. There is no onBackPress lifecycle or popstate event, so onBeforeBack cannot intercept them. This is a platform capability boundary.
Controlling the iOS Swipe-Back Gesture
iOS edge swipe back bypasses the guard chain by default. Use app.setSideSlipGesture to dynamically control the gesture per page:
const router = createRouter({
routes,
app: {
setSideSlipGesture(to) {
// Disable swipe on pages that need interception so back goes through guards
return to.meta.requireLeaveConfirm ? 'none' : 'close'
}
}
})'none': disables iOS swipe-back (back goes through the guard chain,onBeforeBackworks)'close': enables native swipe-back (keeps the native gesture, swipe bypasses guards)
iOS only; Android uses the physical back button, wired into the guard chain via onBackPress.
Relation to onBeforeRouteLeave
onBeforeRouteLeave is implemented via beforeEach, and the back guard chain includes beforeEach, so onBeforeRouteLeave also runs during back operations — returning false blocks the back too.
State Sync Still Handled Automatically
After the back guard passes, the router completes the back. The global mixin still calls syncRoute() in each page's onShow, no manual call needed:
import { onShow } from '@dcloudio/uni-app'
import { useRoute } from '@meng-xi/uni-router'
const route = useRoute()
onShow(() => {
// currentRoute has been auto-synced by the mixin
console.log(route.value.path, route.value.params)
})To listen for all route changes (including state sync), use onRouteChange:
router.onRouteChange((to, from) => {
if (to._synced) {
// State sync (not through the guard chain, e.g. mini-program native back)
handleBackNavigation(to, from)
}
})Cold Start Guard Check
Problem: Cold Start Bypasses Guards
When a user directly enters a page via the following methods, the page is loaded directly by the uni-app framework, bypassing router navigation, and guards (beforeEach etc.) are not executed:
| Scenario | Platform |
|---|---|
| Direct URL access | H5 |
| QR code / scene value | Mini-program |
| Deeplink / URL Scheme | App |
User accesses https://example.com/#/pages/about/about
→ uni-app directly loads the about page
→ Router guards are not executed (no router.push was called)
→ Unauthenticated user directly enters a requireAuth pageSolution: guardRoute()
router.guardRoute() runs the guard chain against the current (or specified) route and decides whether to redirect based on guard results:
// App.vue
import { onLaunch } from '@dcloudio/uni-app'
import { useRouter } from '@meng-xi/uni-router'
const router = useRouter()
onLaunch((options) => {
router.isReady().then(() => {
// At onLaunch, the page stack may be empty (Page.onLoad hasn't fired yet),
// and currentRoute is still START_LOCATION.
// Pass the real entry path from launch options.path to guardRoute,
// ensuring guards check the actual page.
const launchPath = options?.path ? `/${options.path}` : undefined
router.guardRoute(launchPath, {
onAbort: (failure) => {
// Guard aborted (e.g., not logged in), navigate to a safe page
console.warn('Cold start guard aborted:', failure.code)
router.relaunch({ name: 'home' })
}
})
})
})Must pass options.path
When onLaunch fires, the page stack is empty and router.currentRoute is still START_LOCATION (path /). If you call guardRoute(undefined) directly, guards will check / instead of the real entry page, causing guard logic based on to.path / to.name / to.meta to fail.
options.path is provided by the uni-app framework in onLaunch (without leading /, needs manual prepending). It's available on all platforms.
Guard Result Handling
| Guard Result | Behavior |
|---|---|
Pass (return undefined / return true) | No navigation, resolves with the target route |
Redirect (return location) | Navigates to the redirect target using the guard-specified mode (default relaunch) |
Abort (return false) | Calls the onAbort callback and rejects with NavigationFailure |
Cold start cannot truly "block entry"
In cold start scenarios the page is already loaded, so guardRoute() cannot truly prevent the page from displaying. When a guard aborts, using the onAbort callback to execute router.relaunch() to navigate to a safe page is the recommended approach.
Difference from syncRoute
| Method | Purpose | Runs Guards |
|---|---|---|
syncRoute() | Syncs currentRoute to the real page stack state | No |
guardRoute() | Runs the guard chain against the current route | Yes |
Both can be used together:
syncRoute: State sync after physical backguardRoute: Guard re-execution during cold start
See Router Instance - guardRoute() for details.
Guard Type Definitions
// Guard return value type
type NavigationGuardReturn = void | undefined | boolean | RouteLocationRaw | NavigationRedirect | Error | null
// Controllable redirect result
interface NavigationRedirect {
location: RouteLocationRaw
mode?: NavigationRedirectMode
}
// Pre guard (return-value pattern, recommended)
type NavigationGuard = (
to: RouteLocation,
from: RouteLocation
) => NavigationGuardReturn | Promise<NavigationGuardReturn>
// Redirect mode
type NavigationRedirectMode = 'push' | 'replace' | 'relaunch'
// Post hook (receives failure parameter)
type PostNavigationGuard = (
to: RouteLocation,
from: RouteLocation,
failure?: NavigationFailure | null
) => void
// Component leave guard (used with onBeforeRouteLeave)
type RouteLeaveGuard = (
to: RouteLocation,
from: RouteLocation
) => NavigationGuardReturn | Promise<NavigationGuardReturn>
// Back guard return value type (false blocks back, true / undefined allows)
type BackGuardReturn = boolean | void | Promise<boolean | void>
// Back guard function type
type BackGuard = (to: RouteLocation, from: RouteLocation) => BackGuardReturnonBeforeRouteLeave Component Leave Guard
onBeforeRouteLeave is a Composition API function that registers a leave guard within a component's <script setup>. It provides a convenient way to intercept navigation away from the current component's page.
Internally, it registers the guard via router.beforeEach and automatically removes it when the component is unmounted, so no manual cleanup is needed.
Example: Leave Confirmation Dialog
<script setup lang="ts">
import { onBeforeRouteLeave } from '@meng-xi/uni-router'
import { ref } from 'vue'
const hasUnsavedChanges = ref(false)
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
return new Promise((resolve) => {
uni.showModal({
title: 'Confirm',
content: 'You have unsaved changes. Leave anyway?',
success: (res) => {
resolve(res.confirm ? true : false)
}
})
})
}
})
</script>Example: Save Data Before Leaving
<script setup lang="ts">
import { onBeforeRouteLeave } from '@meng-xi/uni-router'
onBeforeRouteLeave(async (to, from) => {
// Auto-save draft before leaving
if (isDirty.value) {
try {
await saveDraft()
uni.showToast({ title: 'Draft saved', icon: 'success' })
} catch (err) {
return false // Abort navigation if save fails
}
}
})
</script>Scope
onBeforeRouteLeave only guards navigations where the current component's page is the from route. It does not affect navigations from other pages or components.
Best Practices
1. Single Responsibility Guards
// ✅ Each guard does one thing
router.beforeEach(checkAuth)
router.beforeEach(checkPermission)
router.beforeEach(checkMaintenance)
// ❌ One guard does everything
router.beforeEach((to, from) => {
// 100 lines of mixed logic...
})2. Use Return-Value Pattern
// ✅ Recommended: return-value pattern, clean and concise
router.beforeEach(async (to, from) => {
const ok = await check()
if (!ok) return { name: 'login' }
})3. Add Termination Conditions for Redirects
// ✅ Avoid loops
router.beforeEach((to, from) => {
if (to.name === 'login' && isLoggedIn()) {
return { name: 'home' } // Already logged in accessing login → go home
}
if (to.meta.requireAuth && !isLoggedIn()) {
return { name: 'login' } // Not logged in accessing protected → go to login
}
})4. Put Data Preloading in beforeResolve
// ✅ Preload after pre-validation passes
router.beforeResolve(async (to) => {
await preloadData(to)
})
// ❌ Putting in beforeEach blocks other guardsNext Steps
- Navigation Flow — Where guards fit in the complete flow
- Recipes — Complete business solutions
- Interceptor Mechanism — Principle of intercepting native APIs
