skip to content

In Vue Router 5, a legacy `beforeEach((to, from, next) => …)` guard logs a deprecation warning and sometimes leaves a navigation pending forever; why, and how do you migrate it?

level: seniorimportance: should knowfreq 40%

answer

  1. count the declared parameters
  2. three params: wait for next
  3. exactly once per pass
  4. deprecated, not yet removed
  5. return what you passed to next

basics

~20 s

A guard declared with a third next parameter is resolved only when next is called, so a branch that forgets it hangs the navigation; next is deprecated in Vue Router 5. Drop the parameter and return the value you passed to next.

solid answer

~40 s

Vue Router 5 looks at how many parameters a guard function declares. With fewer than three it resolves the guard from its return value; with three it waits for `next`, which must be called exactly once per pass. A branch that returns early without calling `next` leaves the navigation pending forever; development builds catch an async or value-returning guard that never calls it and report the missing call, and warn when `next` is called twice. Since 5.0.3 every call to `next` also logs a deprecation warning, the parameter is typed `@deprecated`, and a future major is planned to remove deprecated APIs. Migration is mechanical: remove `next`, then `next()` becomes `return`, `next(false)` becomes `return false`, `next('/login')` becomes `return '/login'`, and `next(error)` becomes `throw error`. Only `next(vm => …)` in `beforeRouteEnter` has no return equivalent.

code

ts · 16 lines
ts
// before: legacy style, hangs when canOpen() is false
router.beforeEach(async (to, from, next) => {
  if (!to.meta.requiresAuth) return next()
  const auth = useAuthStore()
  if (!auth.user) return next({ name: 'login', query: { redirect: to.fullPath } })
  if (!(await auth.canOpen(to))) return
  next()
})

// after: return style, every branch is an explicit verdict
router.beforeEach(async (to) => {
  if (!to.meta.requiresAuth) return
  const auth = useAuthStore()
  if (!auth.user) return { name: 'login', query: { redirect: to.fullPath } }
  if (!(await auth.canOpen(to))) return false
})

go deeper

for a junior

Recall that modern guards return their verdict and that the third next parameter is the deprecated older style.

for a middle

Explain the parameter-count rule, the exactly-once contract for next, and the one-to-one mapping from next calls to return values.

for a senior

Show a safe migration: find every guard with a third parameter, convert each branch to an explicit verdict, and remember that falling off the end now allows.

for a principal

Plan the codebase-wide move ahead of the next major, with lint rules against three-parameter guards so new code does not reintroduce next.

## How the router decides between `next` and a return value A Vue Router 5 guard can answer in two styles. The router picks one per guard by reading the function's declared parameter count, its `length`: - **fewer than three parameters** (`(to)` or `(to, from)`): the router awaits the return value and treats it as the verdict; - **three parameters** (`(to, from, next)`): the router ignores the return value and waits until `next` is called. The second style is the older one. It is still supported in Vue Router 5, but the `next` argument is typed `@deprecated` with a note that it will be removed in a future version, and since **5.0.3** a development warning fires the first time a guard calls `next`, spelling out the replacements. The documentation's stated plan is that the next major removes deprecated APIs. ## Why legacy guards hang `next` must be called **exactly once** on every path through the guard. The typical HR-portal legacy guard breaks that rule on one branch: ```ts router.beforeEach(async (to, from, next) => { if (!to.meta.requiresAuth) return next() const auth = useAuthStore() if (!auth.user) return next({ name: 'login' }) if (!(await auth.canOpen(to))) return // forgot next(false) next() }) ``` What happens on the forgotten branch depends on the build: | Build | Guard never calls `next` | Guard calls `next` twice | |---|---|---| | development | for an async or value-returning guard: an error that `next` was never called, and the navigation fails | a warning that this will fail in production; only the first call counts | | production | the navigation stays pending forever, silently | no check at all; the development warning exists because such code breaks in production | A **synchronous** guard that simply falls off the end without calling `next` is not detected even in development: it returns `undefined`, which the check cannot tell from a guard still waiting to call `next` later, so it just hangs. ## The migration table | Legacy call | Return style | |---|---| | `next()` or `next(true)` | `return` (or return nothing) | | `next(false)` | `return false` | | `next('/login')` or `next({ name: 'login' })` | `return '/login'` or `return { name: 'login' }` | | `next(new Error('…'))` | `throw new Error('…')` | | `next(vm => …)` in `beforeRouteEnter` | no return equivalent | The steps: 1. delete the `next` parameter, so the router switches to return-value mode; 2. replace each `next(x)` with `return x`, and `next(error)` with `throw`; 3. make sure every branch returns deliberately; falling off the end now means **allow**, the opposite of the old hang; 4. search for `next(` in component options too: `beforeRouteLeave` and `beforeRouteUpdate` migrate the same way. ```ts router.beforeEach(async (to) => { if (!to.meta.requiresAuth) return const auth = useAuthStore() if (!auth.user) return { name: 'login' } if (!(await auth.canOpen(to))) return false }) ``` ## The one case without a return equivalent `beforeRouteEnter` runs before the component exists, and `next(vm => …)` is the only way to reach the instance later. That callback form is covered by the same deprecation. Plan to move that work into `setup`, which runs once the instance exists, or into a `beforeEnter`/`beforeResolve` guard that loads data into a store the component reads. ## Finding every legacy guard `next` can appear anywhere a guard is declared, so a migration sweep covers: - `router.beforeEach` and `router.beforeResolve` registrations; - `beforeEnter` on route records, including every function in a `beforeEnter` array; - the Options API component options `beforeRouteEnter`, `beforeRouteUpdate` and `beforeRouteLeave`; - guards passed to `onBeforeRouteLeave` and `onBeforeRouteUpdate`, which follow the same parameter-count rule. `afterEach` hooks never receive `next`; their third parameter is the navigation failure, so they need no change. The development warning helps with the rest: it fires whenever a guard run calls `next`, so running through the app's main flows in development lists the guards still using it. ## Watch the parameter count Because the choice is made from the function's `length`, a guard that declares `next` but never uses it still runs in legacy mode. Linters and code review should flag unused third parameters in guards; removing the parameter is the entire fix. ## Common mistakes - Keeping the `next` parameter "just in case" and returning values, which the router ignores. - Calling `next()` after a redirect call on the same path, which developers see only as a warning. - Returning `router.push()` from a migrated guard instead of the location.

  • A guard is written `(to, from, next) => { if (!ok) return false }` and never calls `next`. What happens in development and in production?
    Because it declares three parameters, the router ignores the returned `false` and waits for `next`. When `ok` is false, development builds report that `next` was never called and fail the navigation; production leaves it pending forever. When `ok` is true the guard returns nothing and still never calls `next`, so that navigation hangs in both builds.
  • Why does falling off the end of a migrated guard allow the navigation?
    In return-value mode the router awaits the guard's result and treats `undefined` like `true`: the navigation is validated. In the legacy style, falling off the end without `next` hung the navigation. After migrating, every deny branch must return `false` or a location explicitly.

saying these in an interview costs you the question

  • next was removed in Vue Router 5, so legacy guards throw immediately
  • An async guard ignores next and always uses its return value
  • Calling next twice is harmless because the second call wins
  • Declaring next without using it has no effect on how the guard resolves
  • Returning a value from a guard that declares next redirects as expected