Skip to content

Navigation

Route navigation is Uni Router's core capability. This chapter dives deep into the four navigation methods, their working principles, uni-app's underlying limitations, and how to leverage the library's features for special use cases.

Four Navigation Methods Overview

MethodStack Operationuni APIDuplicate CheckAnimationeventsReturn Value
push()Push +1navigateTo / switchTabNavigationResult
replace()Replace topredirectTo / switchTab⚠️¹NavigationResult
relaunch()Clear then pushreLaunch / switchTab⚠️¹NavigationResult
back()Pop -nnavigateBackRouteLocation

¹ events not supported by default; with useUniEventChannel: true enabled, replace / relaunch also support page communication and the returned eventChannel is available.

TabBar Page Auto-Detection

When the target route's meta.isTab is true, push / replace / relaunch all automatically switch to uni.switchTab. You don't need to manually check—just declare isTab correctly in the route config.

push — Stack Navigation

push is the most common navigation method, pushing a new page onto the page stack.

ts
router.push(location: RouteLocationRaw): Promise<NavigationResult>

Basic Usage

ts
// Path string
await router.push('pages/about/about')

// Path object + query params
await router.push({ path: 'pages/about/about', query: { id: '1' } })

// Named route (recommended, refactor-friendly)
await router.push({ name: 'about', query: { id: '1' } })

// String with query
await router.push('pages/about/about?id=1&tab=info')

Return Value NavigationResult

push returns NavigationResult, which extends RouteLocation and additionally includes eventChannel:

ts
const result = await router.push({ name: 'detail', query: { id: '1' } })
console.log(result.path)         // '/pages/detail/detail'
console.log(result.query.id)     // '1'
console.log(result.eventChannel) // EventChannel instance (push only)

Backward Compatible

NavigationResult extends RouteLocation, so existing code const route = await router.push(...) works without modification.

Duplicate Navigation Detection

push checks if the target is exactly the same as the current position (path, name, query all match), and throws NAVIGATION_DUPLICATED when they match:

ts
// Currently at /pages/about/about?id=1
await router.push({ name: 'about', query: { id: '1' } })
// → Throws NavigationFailure (NAVIGATION_DUPLICATED)

Only push Checks Duplicates

replace / relaunch / back don't check duplicates. This is because replace is often used to refresh the current page, relaunch to reset to the current page (like returning home after logout), and back may return to a page that's the same as current.

TabBar Page Limitations

When the target is a TabBar page (meta.isTab: true), push switches to uni.switchTab, and these limitations apply:

FeatureRegular PageTabBar Page
query✅ Appended to URL❌ Ignored with warning
animation✅ Works❌ Ignored with warning
events✅ Works❌ Ignored
eventChannel✅ Returnedundefined
ts
// TabBar page, query will be ignored
await router.push({ name: 'user', query: { tab: 'profile' } })
// ⚠️ Warning: uni.switchTab does not support query parameters. They will be ignored.

This is a uni-app hard limitation

uni.switchTab is determined by the mini-program spec and doesn't support URL parameters. To pass params to TabBar pages, use params (see special usage below).

replace — Replace Navigation

replace replaces the current page without increasing stack depth. Commonly used to replace the login page after login, or replace the form page after form submission.

ts
router.replace(location: RouteLocationRaw): Promise<RouteLocation>
ts
// Replace login page after successful login
await router.replace({ name: 'home' })

// Replace with detail page after form submission
await router.replace({ path: 'pages/detail/detail', query: { id: result.id } })

Differences from push

  • No duplicate check: Can replace to current page (for refresh)
  • No eventChannel by default: redirectTo doesn't support native page communication; the returned NavigationResult.eventChannel is undefined (available with useUniEventChannel: true via the built-in channel)
  • events ignored by default: Passing events outputs a warning (takes effect with useUniEventChannel: true)
  • Same TabBar limitations: Switches to switchTab when meta.isTab

relaunch — Reset Navigation

relaunch closes all pages then opens the target page. Commonly used for logout, returning home, or resetting an entire flow.

ts
router.relaunch(location: RouteLocationRaw): Promise<RouteLocation>
ts
// Logout
await router.relaunch({ name: 'login' })

// Return home from deep page
await router.relaunch({ name: 'home' })

// With redirect param
await router.relaunch({ path: 'pages/login/login', query: { redirect: '/about' } })

Special Limitations

reLaunch Doesn't Support Animation

uni.reLaunch doesn't accept animation params. Passing animation outputs a warning and is ignored. This is because reLaunch closes all pages, making animation semantics unclear.

TabBar pages go through switchTab, which also doesn't support animation.

Why No Duplicate Check

relaunch is often used in "reset to current page" scenarios, such as:

  • User clicks "return home" button while on home page
  • Target page after logout happens to be the current page

So relaunch doesn't do duplicate checking to ensure these scenarios work normally.

back — Back Navigation

back returns to the previous page or multiple pages. It's the only "back" operation that executes the full guard chain.

ts
router.back(delta?: number, animation?: NavigationAnimation): Promise<RouteLocation>
ts
// Return one page
await router.back()

// Return two pages
await router.back(2)

// Custom animation
await router.back(1, { type: 'slide-out-right', duration: 500 })

How It Works

back reads the page stack via getCurrentPages() and calculates the target page:

Current stack: [A, B, C, D]  (D is current page)
back(2) → target is B (index = 4-1-2 = 1)
→ calls uni.navigateBack({ delta: 2 })
→ syncs currentRoute to B

If the page stack is insufficient (targetIndex < 0), throws NAVIGATION_CANCELLED.

Guard Interception

back executes the full guard chain (beforeEachbeforeResolve), guards can:

  • next() to allow the return
  • next(false) to block the return (like "unsaved form" prompt)
  • next(location) to redirect to another page
ts
router.beforeEach((to, from, next) => {
  if (from.meta.dirty && !confirm('Unsaved changes will be lost. Leave anyway?')) {
    next(false) // Block return
  } else {
    next()
  }
})

Physical Back Button Cannot Be Intercepted

back() only intercepts programmatic calls. Physical back button (Android), browser back (H5), mini-program top-left return directly trigger native navigateBack, bypassing the router, so guards cannot intercept them.

Solutions:

  1. The router registers a global mixin in install() that automatically calls router.syncRoute() in each page's onShow to sync state (no manual call needed)
  2. Do after-the-fact handling in onRouteChange
  3. App can listen to onBackPress to intercept physical back

RouteLocationRaw

All navigation methods accept the RouteLocationRaw type, supporting three forms:

String Form

ts
router.push('pages/about/about')
router.push('pages/about/about?id=1&tab=info')

Paths are auto-normalized (leading / added). Query in the string is parsed.

Path Object

ts
router.push({
  path: 'pages/about/about',
  query: { id: '1', tab: 'info' },
  params: { detail: { name: 'Tom' } },
  animation: { type: 'slide-in-right' },
  events: { update(data) { /* ... */ } }
})

Named Object

ts
router.push({
  name: 'about',
  query: { id: '1' },
  params: { detail: { name: 'Tom' } }
})

Recommend Named Routes

Named routes decouple from paths. When refactoring, just modify path in the route config—no need to search and replace strings globally. With @meng-xi/vite-plugin's dts feature, you also get type checking and autocompletion.

Special Usage: params for Complex Data

uni-app native navigation only supports URL query (strings). Uni Router's params breaks this limitation:

Passing Arbitrary JSON Data

ts
// Pass object
await router.push({
  path: 'pages/detail/detail',
  params: {
    id: 123,
    info: { name: 'Tom', age: 20 },
    tags: ['a', 'b', 'c']
  }
})

// Target page reads
const route = useRoute()
console.log(route.params.id)      // 123
console.log(route.params.info)    // { name: 'Tom', age: 20 }
console.log(route.params.tags)    // ['a', 'b', 'c']

Implementation Principle

params is not exposed in the URL. Instead, it's stored in an internal Map, and a random key is injected into the URL query (__params_key). The target page reads from the Map using the key.

During navigation:
  params: { id: 123, info: {...} }
  → stored in ParamsManager (key: "abc123")
  → URL: /pages/detail/detail?__params_key=abc123

Target page:
  → reads __params_key from query
  → gets params from ParamsManager
  → route.params = { id: 123, info: {...} }

__params_key URL Retention

Although route.query does not contain __params_key (the matcher removes this internal key during resolution to avoid exposing it to users), the actual navigation URL preserves it. This way, when back() returns to the original page, syncCurrentRoute can reconstruct params from the URL, preventing loss. To access user-visible query, use route.query.

Persistence (H5 Refresh Won't Lose)

By default, params are stored in memory and lost when the page closes. Set persistent: true to persist to storage, so H5 refresh can still read them:

ts
// Per-navigation persistence
await router.push({
  path: 'pages/detail/detail',
  params: { id: 123 },
  persistent: true
})

// Global default persistence
const router = createRouter({
  routes,
  paramsPersistent: true // All params persisted by default
})

params Limitations

  1. Doesn't support functions, Symbols, or other non-JSON-serializable values
  2. TabBar pages: Since switchTab doesn't support query, __params_key cannot be passed, so TabBar pages cannot receive params
  3. relaunch / stack overflow: Will clear or rebuild the page stack, original page params cannot be retained, use global state to pass data

Special Usage: Page Communication

Uni Router provides two page communication modes: native EventChannel (default) and the built-in communication manager (useUniEventChannel: true).

Mode 1: Native EventChannel (Default)

push supports events + eventChannel bidirectional communication, corresponding to uni.navigateTo's EventChannel mechanism:

ts
// Initiating page
const { eventChannel } = await router.push({
  path: 'pages/detail/detail',
  events: {
    // Listen for events from target page
    update(data) { console.log('Received update:', data) }
  }
})

// Send event to target page
eventChannel.emit('init', { message: 'Init data' })
ts
// Target page
import { getCurrentPages } from 'uni-app'

const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const eventChannel = currentPage.getOpenerEventChannel()

eventChannel.on('init', (data) => {
  console.log('Received init:', data)
})

// Send event to initiating page
eventChannel.emit('update', { status: 'loaded' })

Limitations of Native Mode

  • Only push supported: replace / relaunch / back don't support events; passing them will be ignored with a warning (redirectTo / reLaunch / navigateBack don't create EventChannel)
  • Timing issue: uni.navigateTo's success callback may fire before the target page's setup runs; emit then happens before on is registered, causing events to be lost
  • H5 refresh loss: Native channels aren't persisted; refreshing breaks the channel

Mode 2: Built-in Communication Manager (useUniEventChannel)

With createRouter({ useUniEventChannel: true }) enabled, all navigation methods (push / replace / relaunch) use the built-in communication manager, and the target page obtains the channel via usePageChannel():

ts
// Initiator: replace / relaunch also return eventChannel
const { eventChannel } = await router.replace({
  name: 'detail',
  params: { id: 123 },
  events: {
    ready(data) { console.log('Target page ready:', data) }
  }
})

// Send event to target page
eventChannel.emit('init', { message: 'Init data' })
vue
<!-- Target page: use usePageChannel() instead of getOpenerEventChannel() -->
<script setup lang="ts">
import { usePageChannel } from '@meng-xi/uni-router'

const channel = usePageChannel()

// Listen for events from the initiator
channel.on('init', (data) => {
  console.log('Received init:', data)
})

// Send event to the initiator
channel.emit('ready', { status: 'loaded' })
</script>

Advantages of the built-in communication manager:

FeatureNative EventChannelBuilt-in Communication Manager
Applicable navigation methodsOnly pushpush / replace / relaunch
Timing issueEvents lost when emit precedes onSticky cache, no loss
H5 refreshChannel lost__nav_id persisted, can rebuild
Lifecycle cleanupManualAuto-destroyed on page unmount
Target page accessgetOpenerEventChannel()usePageChannel()

Sticky Event Caching

The built-in channel implements a sticky event mechanism: emit always caches the event arguments; when on / once registers a listener and a cache exists, it async-triggers (without deleting the cache). Regardless of the order of emit and on, all listeners receive the data from the last emit, completely solving the timing race.

Notes on Switching Modes

  • When useUniEventChannel is enabled, push no longer uses the native EventChannel; the target page must use usePageChannel()
  • The events option works in both modes, but is forwarded via uni.$emit in built-in mode
  • See usePageChannel() API and useUniEventChannel option

Special Usage: Navigation Animation

App supports custom navigation animation with three-level priority:

Inline animation param > meta.animation > uni default

Route-Level Default Animation

ts
const routes = [
  {
    path: 'pages/about/about',
    name: 'about',
    meta: { animation: { type: 'fade-in', duration: 300 } }
  }
]

Override at Call Time

ts
await router.push({ name: 'about', animation: { type: 'slide-in-bottom', duration: 500 } })
await router.back(1, { type: 'slide-out-right', duration: 500 })

Animation Types

type corresponds to uni.navigateTo's animationType. App options:

ValueDescription
'auto'Auto select
'none'No animation
'slide-in-right'Slide in from right (default)
'slide-in-left'Slide in from left
'slide-in-top'Slide in from top
'slide-in-bottom'Slide in from bottom
'fade-in'Fade in
'zoom-fade-in'Zoom fade in
'zoom-out'Zoom out

Platform Limitation

Animation only works on App. Mini-program and H5 navigation animations are system-controlled and cannot be customized. reLaunch doesn't support animation even on App.

Concurrent Navigation Handling

Uni Router has built-in concurrent navigation queueing:

ts
// Two consecutive navigations
router.push({ name: 'a' })  // Executes immediately
router.push({ name: 'b' })  // Waits for first to complete
Timeline:
  t0: push(a) starts executing
  t1: push(b) enters waiting (pendingNavigation exists)
  t2: push(a) completes → push(b) starts executing
  t3: push(b) completes

Avoid Navigation Conflicts

Concurrent navigation queueing ensures only one navigation is in progress at a time, avoiding page stack corruption. But avoid triggering new navigation in guards, which may cause deadlock.

Redirect Depth Protection

next(location) in guards triggers a redirect. Uni Router limits max redirect depth to 10, throwing NAVIGATION_CANCELLED when exceeded:

ts
// Error example: A→B→A→B→... infinite redirect
router.beforeEach((to, from, next) => {
  if (to.name === 'a') next({ name: 'b' })
  else if (to.name === 'b') next({ name: 'a' })
})
→ push(a) → beforeEach redirects to b
→ push(b) → beforeEach redirects to a
→ ... (after 10 times)
→ Throws NAVIGATION_CANCELLED: Maximum redirect depth (10) exceeded

All navigation methods return Promises that reject with NavigationFailure on failure:

ts
import { NavigationFailure, RouterErrorCode } from '@meng-xi/uni-router'

try {
  await router.push({ name: 'about' })
} catch (error) {
  if (error instanceof NavigationFailure) {
    switch (error.code) {
      case RouterErrorCode.NAVIGATION_ABORTED:
        console.log('Aborted by guard')
        break
      case RouterErrorCode.NAVIGATION_DUPLICATED:
        console.log('Duplicate navigation, already on this page')
        break
      case RouterErrorCode.NAVIGATION_CANCELLED:
        console.log('Cancelled (redirect limit or stack insufficient)')
        break
      case RouterErrorCode.NAVIGATION_API_ERROR:
        console.error('uni API failed', error.cause)
        break
    }
  }
}

You can also catch globally via onError:

ts
router.onError((error, to, from) => {
  // Report error logs
  trackError(error.code, { to: to.path, from: from.path })
})

See Error Handling.

Best Practices

1. Consistently Use Named Routes

ts
// ✅ Recommended
router.push({ name: 'detail', query: { id: '1' } })

// ❌ Not recommended (hardcoded path)
router.push('pages/detail/detail?id=1')

2. Use params for TabBar Page Params

ts
// ✅ TabBar pages use params (query is ignored by switchTab)
router.push({ name: 'user', params: { tab: 'profile' } })

// ❌ query will be ignored
router.push({ name: 'user', query: { tab: 'profile' } })

3. Use relaunch for Logout

ts
// ✅ Clear stack, user cannot return to protected pages
await router.relaunch({ name: 'login' })

// ❌ After replace, user can still go back
await router.replace({ name: 'login' })

4. Use relaunch to Return Home from Deep Pages

ts
// Current stack: [home, list, detail, comment]
// Return directly to home from comment

// ✅ Clear stack, avoid multiple backs
await router.relaunch({ name: 'home' })

// ❌ Multiple backs, poor UX and may have stack issues
await router.back(3)

5. Use back Guard for Form Page Interception

ts
router.beforeEach((to, from, next) => {
  if (from.meta.dirty) {
    uni.showModal({
      title: 'Notice',
      content: 'Unsaved changes will be lost. Leave anyway?',
      success: (res) => res.confirm ? next() : next(false)
    })
  } else {
    next()
  }
})

Next Steps

  • Route Guards — Master guard mechanism and controllable redirects
  • Navigation Flow — Understand the complete flow from push to page display
  • Recipes — Complete solutions for auth, permission control, etc.

Released under the MIT License.