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, all behaviors of next(), and the controllable redirect introduced in v1.7.0.

Guard Overview

Uni Router provides four guards, in 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

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

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
const removeBefore = router.beforeEach((to, from, next) => {
  if (to.meta.requireAuth && !isLoggedIn()) {
    next({ name: 'login' })
  } else {
    next()
  }
})

// Resolve guard
router.beforeResolve(async (to, from, next) => {
  // After all pre guards pass, preload data
  if (to.name === 'detail') {
    await store.fetchDetail(to.query.id)
  }
  next()
})

// Post hook
router.afterEach((to, from) => {
  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, next) => {
      if (user.role === 'admin') next()
      else next({ name: '403' })
    }
  },
  {
    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.

All Behaviors of next()

next is the third parameter of the guard function and must be called to resolve the guard. It has three behaviors:

1. Pass: next() or next(undefined)

ts
router.beforeEach((to, from, next) => {
  next() // Pass, continue to next guard
})

2. Abort: next(false)

ts
router.beforeEach((to, from, next) => {
  if (isOffline()) {
    uni.showToast({ title: 'Network unavailable', icon: 'none' })
    next(false) // Abort navigation, stay on current page
  } else {
    next()
  }
})

Abort throws NavigationFailure (NAVIGATION_ABORTED).

3. Redirect: next(location)

ts
router.beforeEach((to, from, next) => {
  if (to.meta.requireAuth && !isLoggedIn()) {
    // Redirect to login page, carry original target for post-login return
    next({ name: 'login', query: { redirect: to.fullPath } })
  } else {
    next()
  }
})

Redirects re-trigger the complete guard chain (starting from beforeEach) and increment the redirect depth counter.

Controllable Redirect (v1.7.0+)

v1.7.0 New

This feature was introduced in v1.7.0. In previous versions, the redirect method was fixed to the original navigation method that triggered the guard.

Default Redirect Method

When mode is not specified, the redirect uses the original navigation method:

ts
// Original navigation is push
await router.push({ name: 'protected' })
// In beforeEach: next({ name: 'login' })
// → Redirect uses push method (navigateTo)

// Original navigation is replace
await router.replace({ name: 'protected' })
// In beforeEach: next({ name: 'login' })
// → Redirect uses replace method (redirectTo)

back's Special Case

When the original navigation is back, the redirect cannot use back (target is not in the page stack), so it automatically falls back to relaunch.

Specifying Redirect Method

Explicitly specify via the second parameter options.mode:

ts
router.beforeEach((to, from, next) => {
  if (to.meta.requireAuth && !isLoggedIn()) {
    // Use replace for login page, avoiding users returning to the protected page's intermediate state
    next({ name: 'login' }, { mode: 'replace' })
  } else {
    next()
  }
})

mode Options

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

Practice: Choosing Login Redirect Method

ts
router.beforeEach((to, from, next) => {
  if (to.meta.requireAuth && !isLoggedIn()) {
    if (from.name === 'login') {
      // Already on login page without permissions, use replace to avoid stack buildup
      next(false)
    } else {
      // Use replace to go to login page, then replace back to target after login success
      // This way users won't return to the "unauthenticated intermediate state"
      next({ name: 'login', query: { redirect: to.fullPath } }, { mode: 'replace' })
    }
  } else {
    next()
  }
})

// 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, next) => {
  if (to.meta.roles && !hasRole(to.meta.roles)) {
    // Insufficient permissions, clear stack and return home
    next({ name: 'home' }, { mode: 'relaunch' })
  } else {
    next()
  }
})

Async Guards

Guards support async functions and returning Promises:

ts
router.beforeEach(async (to, from, next) => {
  // Async validate token validity
  const valid = await checkToken()
  if (!valid) {
    next({ name: 'login' })
  } else {
    next()
  }
})

Promise Resolve Auto-Pass

If the guard is an async function and the Promise resolves without calling next, it auto-passes:

ts
router.beforeEach(async (to, from, next) => {
  await preloadData(to)
  // Didn't call next, but Promise resolved → auto next()
})

Recommend Explicit next Call

While auto-pass is convenient, explicitly calling next() is more readable and avoids ambiguity in complex logic.

Promise Reject Aborts Navigation

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

reject vs next(false)

  • next(false)NAVIGATION_ABORTED (user actively aborts)
  • throw / rejectNAVIGATION_CANCELLED (exception causes cancellation)

Recommend using next(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 call next() or resolve/reject 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, next) => {
  next(false) // Abort
})

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

Redirect Re-triggers Guard Chain

ts
router.beforeEach((to, from, next) => {
  if (to.name === 'a') {
    next({ name: 'b' }) // Redirect to b
    return
  }
  next()
})

router.beforeEach((to, from, next) => {
  // When redirecting to b, this guard executes again
  console.log(to.name) // 'b'
  next()
})
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):

ts
router.afterEach((to, from) => {
  // 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()
  2. Physical back button, browser back (bypass router)

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, next) => {
  const isLoggedIn = !!uni.getStorageSync('token')

  if (to.meta.requireAuth && !isLoggedIn) {
    // Not logged in → go to login page, replace to avoid returning to protected page
    next({ name: 'login', query: { redirect: to.fullPath } }, { mode: 'replace' })
  } else if (to.name === 'login' && isLoggedIn) {
    // Already logged in accessing login page → go to home
    next({ name: 'home' }, { mode: 'replace' })
  } else {
    next()
  }
})

Pattern 2: Permission Control

ts
// Extend RouteMeta
declare module '@meng-xi/uni-router' {
  interface RouteMeta {
    roles?: string[]
  }
}

router.beforeEach((to, from, next) => {
  const userRoles = getUserRoles()

  if (to.meta.roles && !to.meta.roles.some(r => userRoles.includes(r))) {
    // Insufficient permissions → clear stack and return home
    next({ name: 'home' }, { mode: 'relaunch' })
  } else {
    next()
  }
})

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, next) => {
  if (from.meta.dirty) {
    uni.showModal({
      title: 'Notice',
      content: 'You have unsaved changes. Leave anyway?',
      success: (res) => {
        if (res.confirm) {
          from.meta.dirty = false // Reset
          next()
        } else {
          next(false)
        }
      }
    })
  } else {
    next()
  }
})

Pattern 4: Data Preloading

ts
// Preload in beforeResolve (all pre-validation has passed)
router.beforeResolve(async (to, from, next) => {
  try {
    switch (to.name) {
      case 'detail':
        await store.fetchDetail(to.query.id)
        break
      case 'list':
        await store.fetchList(to.queryInt('page', 1))
        break
    }
    next()
  } catch (err) {
    uni.showToast({ title: 'Load failed', icon: 'none' })
    next(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, next) => {
        if (!store.selectedAddress) {
          uni.showToast({ title: 'Please select an address first', icon: 'none' })
          next(false)
        } else {
          next()
        }
      },
      // Must have products
      (to, from, next) => {
        if (store.cart.length === 0) {
          next({ name: 'cart' })
        } else {
          next()
        }
      }
    ]
  }
]

Guards and Physical Back Button

Core Limitation

Physical back button, browser back, mini-program top-left return bypass the router, guards cannot intercept them.

This is an inherent uni-app framework limitation, not a library shortcoming.

Solutions

Solution 1: App listens to onBackPress

ts
// App only
onBackPress((options) => {
  if (pageState.dirty) {
    showConfirmDialog()
    return true // Block default back
  }
  return false // Allow back
})

Solution 2: Auto Sync State in onShow

The router registers a global mixin in install() that automatically calls router.syncRoute() in each page's onShow to sync currentRoute to the real page, no manual call needed:

ts
// The router internally registers:
// app.mixin({ onShow() { router.syncRoute() } })

// So your pages usually don't need to manually sync; just read in onShow / onRouteChange
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)
})

If you need route info in onLoad (earlier than onShow), you can manually call router.syncRoute() once.

Solution 3: After-the-fact Handling in onRouteChange

ts
router.onRouteChange((to, from) => {
  if (to._synced) {
    // State sync (may be triggered by physical 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 (next())No navigation, resolves with the target route
Redirect (next(location))Navigates to the redirect target using the guard-specified mode (default relaunch)
Abort (next(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
// Pre guard
type NavigationGuard = (
  to: RouteLocation,
  from: RouteLocation,
  next: NavigationGuardNext
) => void | Promise<void>

// next callback
type NavigationGuardNext = (
  to?: RouteLocationRaw | false,
  options?: NavigationGuardNextOptions
) => void

// next options
interface NavigationGuardNextOptions {
  mode?: NavigationRedirectMode // 'push' | 'replace' | 'relaunch'
}

// Post hook
type PostNavigationGuard = (
  to: RouteLocation,
  from: RouteLocation
) => void

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, next) => {
  // 100 lines of mixed logic...
})

2. Explicitly Call next

ts
// ✅ Clear
router.beforeEach(async (to, from, next) => {
  const ok = await check()
  next(ok ? undefined : { name: 'login' })
})

// ⚠️ Relies on auto-pass, poor readability
router.beforeEach(async (to, from) => {
  await check()
})

3. Add Termination Conditions for Redirects

ts
// ✅ Avoid loops
router.beforeEach((to, from, next) => {
  if (to.name === 'login' && isLoggedIn()) {
    next({ name: 'home' }) // Already logged in accessing login → go home
  } else if (to.meta.requireAuth && !isLoggedIn()) {
    next({ name: 'login' }) // Not logged in accessing protected → go to login
  } else {
    next()
  }
})

4. Put Data Preloading in beforeResolve

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

// ❌ Putting in beforeEach blocks other guards

Next Steps

Released under the MIT License.