Skip to content

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 result

Back 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

GuardRegistrationTypical Scenarios
beforeEachrouter.beforeEach(fn)Auth check, permission check, global logging
beforeEnterRouteConfig.beforeEnterRoute-specific validation (like reading specific data)
beforeResolverouter.beforeResolve(fn)Final confirmation after data preload completes
afterEachrouter.afterEach(fn)Set title, analytics, cleanup state, receive failure info
onBeforeBackrouter.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

ts
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

ts
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

ts
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

ts
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

ts
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

ts
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 ValueBehavior
undefined / void / truePass, continue to next guard
falseAbort 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 objectCancel navigation (NAVIGATION_CANCELLED)
Thrown exceptionCancel 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.

ts
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 NavigationGuard ReturnActual 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

ts
type NavigationRedirectMode = 'push' | 'replace' | 'relaunch'
modeuni APIUse Case
'push'navigateToNeed to return to original page after login, keep target page in stack
'replace'redirectToReplace current page, no history (e.g. login page)
'relaunch'reLaunchClear stack (e.g. return home on insufficient permissions)

Practice: Login Redirect

ts
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

ts
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:

ts
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

ts
router.beforeEach(async (to, from) => {
  try {
    await fetchUserProfile()
    // Pass
  } catch (err) {
    // reject will abort navigation (NAVIGATION_CANCELLED)
    throw err
  }
})

Return Value vs Exception

  • return falseNAVIGATION_ABORTED (user actively aborts)
  • throw / rejectNAVIGATION_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:

ts
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:

ts
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:

ts
router.beforeEach(guard1) // Executes first
router.beforeEach(guard2) // Executes second
router.beforeEach(guard3) // Executes last
guard1 → guard2 → guard3 → beforeEnter → beforeResolve1 → beforeResolve2 → API

Short-Circuit Effect of Abort/Redirect

If any guard aborts or redirects, subsequent guards won't execute:

ts
router.beforeEach((to, from) => {
  return false // Abort
})

router.beforeEach((to, from) => {
  console.log('Will not execute')
})

Redirect Re-triggers Guard Chain

ts
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:

ts
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:

  1. 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.

ts
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

ts
// 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

ts
// 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

ts
// 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

ts
// 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

ts
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

ts
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.

ts
// 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 scenarioAppH5Mini-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:

ts
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, onBeforeBack works)
  • '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:

ts
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:

ts
router.onRouteChange((to, from) => {
  if (to._synced) {
    // State sync (not through the guard chain, e.g. mini-program native back)
    handleBackNavigation(to, from)
  }
})

See Platform Compatibility.

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:

ScenarioPlatform
Direct URL accessH5
QR code / scene valueMini-program
Deeplink / URL SchemeApp
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 page

Solution: guardRoute()

router.guardRoute() runs the guard chain against the current (or specified) route and decides whether to redirect based on guard results:

ts
// 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 ResultBehavior
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

MethodPurposeRuns Guards
syncRoute()Syncs currentRoute to the real page stack stateNo
guardRoute()Runs the guard chain against the current routeYes

Both can be used together:

  • syncRoute: State sync after physical back
  • guardRoute: Guard re-execution during cold start

See Router Instance - guardRoute() for details.

Guard Type Definitions

ts
// 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) => BackGuardReturn

onBeforeRouteLeave 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

ts
<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

ts
<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

ts
// ✅ 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

ts
// ✅ 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

ts
// ✅ 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

ts
// ✅ Preload after pre-validation passes
router.beforeResolve(async (to) => {
  await preloadData(to)
})

// ❌ Putting in beforeEach blocks other guards

Next Steps

Released under the MIT License.