Vue Routerのナビゲーションガードとは、ページを移動する直前に関数を挟み、「通す・止める・別のページへ送る」を決められる仕組みです。 ログインしていない人をログイン画面へ送る処理は、全ルート共通のガードである beforeEach に書くのが基本形です。

この記事では、ガードの種類と実行順、戻り値ごとの挙動、meta.requiresAuth と Pinia を組み合わせた認証リダイレクト一式を、すべて実際に動かした出力つきで紹介します。ログイン後に元のページへ戻す redirect クエリ、無限リダイレクトの再現と直し方、RouteMeta の型拡張、next() を2回呼んだときの警告文まで、コピーして動かせる形でまとめました。

確認環境:Ubuntu 24.04 / Node.js 22.22.2 / vue 3.5.43 / vue-router 5.3.1 / pinia 4.0.3 / typescript 7.0.2 / jsdom 30.1.1。ブラウザの代わりに jsdom と createMemoryHistory を使い、Node.js 上で実行しています。

ナビゲーションガードの早見表

ガード 書く場所 呼ばれるタイミング 向いている用途
router.beforeEach ルーター全体 すべての移動の最初のほう ログイン確認・権限確認
beforeEnter ルート定義 そのルートに入るときだけ 特定ページだけの条件
beforeRouteEnter コンポーネント コンポーネントが作られる前 表示前のデータ確認
beforeRouteUpdate / onBeforeRouteUpdate コンポーネント 同じコンポーネントのままパラメータが変わるとき /users/1 から /users/2 への再読み込み
beforeRouteLeave / onBeforeRouteLeave コンポーネント そのページを離れるとき 入力途中の離脱確認
router.beforeResolve ルーター全体 移動が確定する直前 全ガードと非同期コンポーネントの読み込み後に行う処理
router.afterEach ルーター全体 移動が確定した後 ページタイトルの変更・アクセス記録(移動は止められない)

ガードの戻り値で移動の行き先が決まります。

戻り値 挙動
何も返さない(undefined)または true そのまま通す
false 移動を中止し、元のページにとどまる
パス文字列 '/login' またはオブジェクト { name: 'login', query: {...} } その場所へリダイレクトする
throw new Error(...) 移動を中止し、router.onError に渡される

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vue Routerナビゲーションガード|beforeEachで認証リダイレクト」

動かすための準備

この記事のコードは、次の手順でそのまま動かせます。作業用のフォルダを作り、パッケージを入れます。

npm init -y
npm i vue vue-router pinia jsdom

コンポーネントを画面に出す例では DOM が必要になるため、jsdom をグローバルに登録する小さなファイルを用意します。

// setup.mjs
import { JSDOM } from 'jsdom'
const dom = new JSDOM('<!doctype html><html><body><div id="app"></div></body></html>', { url: 'http://localhost/' })
for (const k of ['window','document','navigator','Node','Element','HTMLElement','SVGElement','MathMLElement','Event','history','location']) {
  try { globalThis[k] = dom.window[k] } catch (e) { Object.defineProperty(globalThis, k, { value: dom.window[k], configurable: true }) }
}

記事中のルーターは、URL をメモリー上で管理する createMemoryHistory で作っています。ブラウザで動かすアプリでは、ここを createWebHistory に置き換えます。ガードの書き方は同じです。

ガードの実行順を console.log で確かめる

グローバル・ルート単位・コンポーネント内の全ガードにログを入れて、3つの移動パターンを順に実行します。

// order.mjs
import './setup.mjs'
import { createApp, h } from 'vue'
import { createRouter, createMemoryHistory, RouterView, onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'

const Home = {
  render: () => h('h1', 'Home'),
  beforeRouteLeave(to, from) { console.log('  [Home] beforeRouteLeave') },
}
const User = {
  render() { return h('h1', 'User ' + this.$route.params.id) },
  beforeRouteEnter(to, from) { console.log('  [User] beforeRouteEnter') },
  beforeRouteUpdate(to, from) { console.log('  [User] beforeRouteUpdate') },
  beforeRouteLeave(to, from) { console.log('  [User] beforeRouteLeave') },
  mounted() { console.log('  [User] mounted') },
}
const router = createRouter({
  history: createMemoryHistory(),
  routes: [
    { path: '/', component: Home },
    {
      path: '/users/:id',
      component: () => { console.log('  (非同期コンポーネントを読み込み)'); return Promise.resolve(User) },
      beforeEnter(to, from) { console.log('  [route] beforeEnter') },
    },
  ],
})
router.beforeEach((to, from) => { console.log('  [global] beforeEach') })
router.beforeResolve((to, from) => { console.log('  [global] beforeResolve') })
router.afterEach((to, from, failure) => { console.log('  [global] afterEach') })

const app = createApp({ render: () => h(RouterView) })
app.use(router)
const log = console.log; console.log = () => {}
await router.push('/'); await router.isReady(); app.mount('#app')
console.log = log
console.log('--- / → /users/1'); await router.push('/users/1')
console.log('  DOM:', document.body.textContent)
console.log('--- /users/1 → /users/2(同じコンポーネントを再利用)'); await router.push('/users/2')
console.log('  DOM:', document.body.textContent)
console.log('--- /users/2 → /'); await router.push('/')
console.log('  DOM:', document.body.textContent)
node order.mjs

実行結果:

--- / → /users/1
  [global] beforeEach
  [route] beforeEnter
  (非同期コンポーネントを読み込み)
  [User] beforeRouteEnter
  [global] beforeResolve
  [global] afterEach
  [User] mounted
  DOM: User 1
--- /users/1 → /users/2(同じコンポーネントを再利用)
  [global] beforeEach
  [User] beforeRouteUpdate
  [global] beforeResolve
  [global] afterEach
  DOM: User 2
--- /users/2 → /
  [User] beforeRouteLeave
  [global] beforeEach
  [global] beforeResolve
  [global] afterEach
  DOM: Home

この出力から、次のことがわかります。

  • 新しいページに入るときは、beforeEach → beforeEnter → 非同期コンポーネントの読み込み → beforeRouteEnter → beforeResolve → afterEach の順で、コンポーネントの mounted は afterEach の後です。
  • 同じコンポーネントのままパラメータだけが変わるとき(/users/1 → /users/2)は、beforeEnter と beforeRouteEnter は呼ばれず、代わりに beforeRouteUpdate が呼ばれます。
  • ページを離れるときは、beforeRouteLeave が beforeEach より先に呼ばれます。

公式ドキュメント(Navigation Guards の「The Full Navigation Resolution Flow」)には、全体の流れが12段階で載っています。上のログと照らし合わせると次のとおりです。

順番 公式ドキュメントの記載 上のログで対応する行
1 ナビゲーションが始まる router.push(...)
2 離れるコンポーネントの beforeRouteLeave [User] beforeRouteLeave
3 グローバルの beforeEach [global] beforeEach
4 再利用されるコンポーネントの beforeRouteUpdate [User] beforeRouteUpdate
5 ルート定義の beforeEnter [route] beforeEnter
6 非同期ルートコンポーネントの解決 (非同期コンポーネントを読み込み)
7 入るコンポーネントの beforeRouteEnter [User] beforeRouteEnter
8 グローバルの beforeResolve [global] beforeResolve
9 ナビゲーションが確定する (URL が切り替わる)
10 グローバルの afterEach [global] afterEach
11 DOM の更新 [User] mounted
12 beforeRouteEnter の next に渡したコールバック (この例では使っていません)

認証の確認を beforeEach に置くと、ルート単位やコンポーネント内のガード、非同期コンポーネントの読み込みより前に判定できます。 逆に「すべてのガードを通過し、コンポーネントの読み込みも終わった後で実行したい処理」は beforeResolve が合います。

beforeRouteEnter では this を使えない

公式ドキュメントには、beforeRouteEnter はナビゲーションが確定する前に呼ばれ、その時点ではコンポーネントがまだ作られていないため this にアクセスできない、と書かれています。インスタンスが必要なときは next にコールバックを渡すと、確定後にインスタンスを引数にして呼ばれます(上の表の12番目)。beforeRouteUpdate と beforeRouteLeave は、すでにあるコンポーネントに対して呼ばれるガードです。

戻り値ごとの挙動を確かめる

同じ /a への移動に対して、beforeEach の戻り値だけを切り替えて結果を比べます。router.push が返す値と、移動後の URL も一緒に表示します。

// ret.mjs
import { createRouter, createMemoryHistory, isNavigationFailure, NavigationFailureType } from 'vue-router'
const C = { render: () => null }
const router = createRouter({
  history: createMemoryHistory(),
  routes: ['/', '/a', '/b', '/login'].map(path => ({ path, component: C })).concat([{ path: '/named', name: 'named', component: C }]),
})
let mode
router.beforeEach((to, from) => {
  if (to.path !== '/a') return
  switch (mode) {
    case 'undefined': return
    case 'true': return true
    case 'false': return false
    case 'string': return '/login'
    case 'object': return { path: '/login', query: { redirect: to.fullPath } }
    case 'name': return { name: 'named', replace: true }
    case 'throw': throw new Error('ユーザー情報を取得できませんでした')
  }
})
router.afterEach((to, from, failure) => {
  if (to.path === '/' || to.path === '/b') return // 準備用の移動は表示しない
  console.log('   afterEach: to=' + to.fullPath + (failure ? ' failure=' + failure.message : ''))
})
router.onError((err) => console.log('   onError:', err.message))
await router.push('/'); await router.isReady()
for (mode of ['undefined', 'true', 'false', 'string', 'object', 'name', 'throw']) {
  await router.push('/b')
  console.log('■ 戻り値 = ' + mode)
  let result
  try { result = await router.push('/a') } catch (e) { result = 'reject: ' + e.message }
  console.log('   push の結果:', result === undefined ? 'undefined(成功)' : (isNavigationFailure(result, NavigationFailureType.aborted) ? 'NavigationFailure(aborted)' : String(result)))
  console.log('   現在のURL:', router.currentRoute.value.fullPath)
}

実行結果:

■ 戻り値 = undefined
   afterEach: to=/a
   push の結果: undefined(成功)
   現在のURL: /a
■ 戻り値 = true
   afterEach: to=/a
   push の結果: undefined(成功)
   現在のURL: /a
■ 戻り値 = false
   afterEach: to=/a failure=Navigation aborted from "/b" to "/a" via a navigation guard.
   push の結果: NavigationFailure(aborted)
   現在のURL: /b
■ 戻り値 = string
   afterEach: to=/login
   push の結果: undefined(成功)
   現在のURL: /login
■ 戻り値 = object
   afterEach: to=/login?redirect=/a
   push の結果: undefined(成功)
   現在のURL: /login?redirect=/a
■ 戻り値 = name
   afterEach: to=/named
   push の結果: undefined(成功)
   現在のURL: /named
■ 戻り値 = throw
   onError: ユーザー情報を取得できませんでした
   push の結果: reject: ユーザー情報を取得できませんでした
   現在のURL: /b

結果を表にまとめます。

ガードの戻り値 移動後のURL await router.push() の結果 afterEach
undefined(return なし) /a undefined 呼ばれる
true /a undefined 呼ばれる
false /b のまま NavigationFailure(aborted) 第3引数に failure が入って呼ばれる
'/login' /login undefined リダイレクト先で呼ばれる
{ path: '/login', query: {...} } /login?redirect=/a undefined リダイレクト先で呼ばれる
{ name: 'named', replace: true } /named undefined リダイレクト先で呼ばれる
throw new Error(...) /b のまま Promise が reject される 呼ばれず、onError が呼ばれる

押さえておきたい点は3つです。

  • false を返しても例外にはなりません。 router.push は NavigationFailure というオブジェクトを返します。中止されたかどうかは isNavigationFailure(result, NavigationFailureType.aborted) で判定できます。
  • afterEach は中止されたときも呼ばれます。 第3引数 failure の有無で、確定した移動かどうかを見分けられます。アクセス記録を取るときは failure が無い場合だけ記録すると、表示されたページと記録が一致します。
  • ガードの中で例外を投げると、router.push の Promise が reject されます。 router.onError を登録しておくと、1か所でまとめて受け取れます。

meta.requiresAuth と Pinia で認証リダイレクトを作る

ここからが本題です。次の4つの動きを1つのファイルにまとめます。

  1. ログインが必要なページには meta: { requiresAuth: true } を付ける
  2. 未ログインで開いたら、ログイン画面へ送る。そのとき開こうとしたURLを redirect クエリに入れておく
  3. ログインに成功したら、redirect クエリのURLへ戻す
  4. ログイン済みの人がログイン画面を開いたら、ホームへ送る
// auth.mjs
import './setup.mjs'
import { createApp, h } from 'vue'
import { createPinia, defineStore } from 'pinia'
import { createRouter, createMemoryHistory, RouterView } from 'vue-router'

// ---- stores/auth.js に相当 ----
export const useAuthStore = defineStore('auth', {
  state: () => ({ user: null }),
  getters: { isLoggedIn: (state) => state.user !== null },
  actions: {
    async login(name, password) {
      await new Promise((r) => setTimeout(r, 50)) // APIの代わり
      this.user = { name }
    },
    logout() { this.user = null },
  },
})

// ---- router/index.js に相当 ----
const page = (name) => ({ render: () => h('h1', name) })
const router = createRouter({
  history: createMemoryHistory(),
  routes: [
    { path: '/', component: page('Home') },
    { path: '/login', name: 'login', component: page('Login'), meta: { guestOnly: true } },
    {
      path: '/mypage', component: { render: () => h(RouterView) }, meta: { requiresAuth: true },
      children: [
        { path: '', component: page('MyPage') },
        { path: 'orders/:id', component: page('OrderDetail') }, // 親の meta を引き継ぐ
      ],
    },
  ],
})

router.beforeEach((to, from) => {
  const auth = useAuthStore() // ガードの中で呼ぶ
  if (to.meta.requiresAuth && !auth.isLoggedIn) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }
  if (to.meta.guestOnly && auth.isLoggedIn) {
    return '/'
  }
})

// ログイン後に戻る先を決める(アプリ内のパスだけを通す)
function safeRedirect(value) {
  const path = Array.isArray(value) ? value[0] : value
  if (typeof path === 'string' && path.startsWith('/') && !path.startsWith('//')) return path
  return '/'
}

// ---- main.js に相当 ----
const app = createApp({ render: () => h(RouterView) })
app.use(createPinia())
app.use(router)
await router.push('/'); await router.isReady()
app.mount('#app')

const show = (label) => console.log(label.padEnd(34), '→', router.currentRoute.value.fullPath, '|', document.body.textContent)
show('1. 最初の表示')
await router.push('/mypage/orders/42?tab=history')
show('2. 未ログインで /mypage/orders/42')
console.log('   to.meta の中身(子ルート):', JSON.stringify(router.resolve('/mypage/orders/42').meta))

// Login.vue の送信処理に相当
const auth = useAuthStore()
await auth.login('taro', 'secret')
await router.replace(safeRedirect(router.currentRoute.value.query.redirect))
show('3. ログイン後')
await router.push('/login')
show('4. ログイン済みで /login')
auth.logout()
await router.push('/mypage')
show('5. ログアウト後に /mypage')
for (const v of ['/mypage', 'https://example.com/', '//example.com', undefined, ['/a', '/b']]) {
  console.log('   safeRedirect(' + JSON.stringify(v) + ') =', safeRedirect(v))
}

実行結果:

1. 最初の表示                           → / | Home
2. 未ログインで /mypage/orders/42        → /login?redirect=/mypage/orders/42?tab=history | Login
   to.meta の中身(子ルート): {"requiresAuth":true}
3. ログイン後                           → /mypage/orders/42?tab=history | OrderDetail
4. ログイン済みで /login                  → / | Home
5. ログアウト後に /mypage                 → /login?redirect=/mypage | Login
   safeRedirect("/mypage") = /mypage
   safeRedirect("https://example.com/") = /
   safeRedirect("//example.com") = /
   safeRedirect(undefined) = /
   safeRedirect(["/a","/b"]) = /a

未ログインで /mypage/orders/42?tab=history を開くと /login?redirect=/mypage/orders/42?tab=history へ送られ、ログイン後はクエリ(?tab=history)まで含めて元のページに戻っています。

ポイント1:to.fullPath を redirect に入れる

to.path ではなく to.fullPath を使うと、クエリ文字列やハッシュも含めて戻せます。 一覧ページの絞り込み条件や、タブの選択状態をURLで持っているページでも、そのまま復元できます。

ポイント2:meta は親から子へ引き継がれる

出力の2番目で、子ルート /mypage/orders/42 の meta が {"requiresAuth":true} になっています。子ルートには meta を書いていませんが、親の /mypage に付けた値が入っています。公式ドキュメント(Route Meta Fields)には、route.meta は親から子までのすべての meta を非再帰的にマージしたもの、と書かれています。ログインが必要な領域は、親ルートに1回書くだけで配下すべてに効きます。 to.matched.some(record => record.meta.requiresAuth) と書かなくても、to.meta.requiresAuth で判定できます。

ポイント3:ストアはガードの中で取得する

useAuthStore() は、beforeEach の関数の中で呼んでいます。ルーターのファイルの先頭(モジュールの直下)で呼ぶと、app.use(createPinia()) より前に実行されることになり、次のメッセージが出ます。

[🍍]: "getActivePinia()" was called but there was no active Pinia. Are you trying to use a store before calling "app.use(pinia)"?
See https://pinia.vuejs.org/core-concepts/outside-component-usage.html for help.
This will fail in production.

ガードの中で呼べば、実行されるのは移動のたび(Pinia の登録後)なので、この問題は起きません。 main.js では app.use(createPinia()) を app.use(router) より先に書いておくと、最初の移動のときにもストアが使えます。

ポイント4:redirect の値はアプリ内のパスだけ通す

redirect クエリはURLの一部なので、誰でも好きな値を入れられます。safeRedirect は「/ で始まり、// では始まらない文字列」だけを通し、それ以外はホームへ送る関数です。出力の最後の5行が、その動きです。https://example.com/ や //example.com はホームに置き換わっています。同じクエリが2つ付いた場合は配列になるため、先頭だけを使っています。

ブラウザ向けのファイルに分けるときの対応

上のコードは1ファイルで動かすためにまとめています。アプリのフォルダ構成に置くときは、ストアを src/stores/auth.js、ルーターとガードを src/router/index.js(history は createWebHistory)に分け、src/main.js で app.use(createPinia()) → app.use(router) の順に登録します。ログイン画面のコンポーネントでは、auth.login() の後に router.replace(safeRedirect(route.query.redirect)) を呼びます。

ログイン後の移動に push ではなく replace を使うと、履歴にログイン画面が残りません。ブラウザの「戻る」でログイン画面に戻ってしまう動きを避けられます。

なお、ガードは画面の表示を切り替える仕組みです。データを返すAPI側でも、ログイン状態と権限の確認を行う構成にしておくと、画面とデータの両方で守れます。

無限リダイレクトを再現して直す

認証ガードで最初に出会いやすいのが、無限リダイレクトです。「未ログインなら必ずログイン画面へ」という条件を、ログイン画面そのものにも適用すると起きます。

// loop.mjs
import { createRouter, createMemoryHistory } from 'vue-router'
const C = { render: () => null }
const make = () => createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: C }, { path: '/login', name: 'login', component: C }, { path: '/mypage', component: C, meta: { requiresAuth: true } }] })
const isLoggedIn = false

console.log('=== 直す前:/login も含めて毎回 /login へ返す ===')
{
  const router = make()
  let count = 0
  router.beforeEach((to) => {
    count++
    if (!isLoggedIn) return { name: 'login' }
  })
  router.onError((e) => console.log('onError:', e.message))
  try { await router.push('/mypage') } catch (e) { console.log('reject:', e.message) }
  console.log('beforeEach が呼ばれた回数:', count, '/ 現在のURL:', router.currentRoute.value.fullPath)
}
console.log('=== 直した後:行き先が login のときは通す ===')
{
  const router = make()
  let count = 0
  router.beforeEach((to) => {
    count++
    if (!isLoggedIn && to.name !== 'login') return { name: 'login' }
  })
  await router.push('/mypage')
  console.log('beforeEach が呼ばれた回数:', count, '/ 現在のURL:', router.currentRoute.value.fullPath)
}

実行結果:

=== 直す前:/login も含めて毎回 /login へ返す ===
[VUE_ROUTER_R0009] Detected a possibly infinite redirection in a navigation guard when going from "/" to "/login". Aborting to avoid a Stack Overflow. This might break in production if not fixed.
├▶ fix: A guard is returning a new location on every call; make that return conditional so it only redirects when actually needed.
╰▶ see: https://router.vuejs.org/guide/advanced/navigation-guards.html#Global-Before-Guards
reject: Infinite redirect in navigation guard
beforeEach が呼ばれた回数: 32 / 現在のURL: /
=== 直した後:行き先が login のときは通す ===
beforeEach が呼ばれた回数: 2 / 現在のURL: /login

直す前は、beforeEach が32回呼ばれたところで Vue Router が検知し、Infinite redirect in navigation guard というエラーで移動を止めています。警告文の fix の行にも「毎回新しい場所を返している。必要なときだけリダイレクトするよう条件を付ける」という趣旨が書かれています。

直し方は「リダイレクト先のルートは、その条件から外す」ことです。 代表的な方法は2つです。

方法 書き方 向いている場面
行き先の名前で外す if (!isLoggedIn && to.name !== 'login') 全ページをログイン必須にするアプリ
meta で対象を絞る if (to.meta.requiresAuth && !isLoggedIn) 公開ページとログイン必須ページが混ざるアプリ

前の章の auth.mjs は2番目の方法です。ログイン画面には requiresAuth が付いていないので、条件に入りません。「ログイン済みなら /login からホームへ」の条件を足すときも、送り先のホームが guestOnly でないことを確認しておくと、同じ形のループを避けられます。

next() を2回呼んだときの警告と、return への書き換え

以前のバージョンの Vue Router では、第3引数の next を呼んで行き先を決める書き方がありました。公式ドキュメントでは、next は引き続き使えるものの、1回のガードの中で必ず1回だけ呼ぶ必要がある、と説明されています。よくある書き方で試します。

// next2.mjs
import { createRouter, createMemoryHistory } from 'vue-router'
const C = { render: () => null }
const mk = () => createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: C }, { path: '/login', component: C }, { path: '/mypage', component: C, meta: { requiresAuth: true } }] })
const isLoggedIn = false
console.log('=== A: next を2回呼ぶ ===')
{ const router = mk()
  router.beforeEach((to, from, next) => {
    if (to.meta.requiresAuth && !isLoggedIn) next('/login')
    next()
  })
  await router.push('/mypage'); console.log('現在のURL:', router.currentRoute.value.fullPath) }
console.log('=== B: next を受け取るが呼ばない ===')
{ const router = mk()
  router.beforeEach((to, from, next) => { if (to.path === '/mypage') { /* next() を忘れた */ } else next() })
  const r = await Promise.race([router.push('/mypage').then(() => '完了'), new Promise(r => setTimeout(() => r('200ms待っても完了しない'), 200))])
  console.log('push:', r, '/ 現在のURL:', router.currentRoute.value.fullPath) }
console.log('=== C: return で書く ===')
{ const router = mk()
  router.beforeEach((to) => { if (to.meta.requiresAuth && !isLoggedIn) return '/login' })
  await router.push('/mypage'); console.log('現在のURL:', router.currentRoute.value.fullPath) }

実行結果:

=== A: next を2回呼ぶ ===
[VUE_ROUTER_R0025] The `next()` callback in navigation guards is deprecated.
├▶ fix: Return the value instead: `next()` becomes `return`, `next(false)` becomes `return false`, `next("/path")` becomes `return "/path"`.
╰▶ see: https://router.vuejs.org/guide/advanced/navigation-guards.html#Optional-third-argument-next
[VUE_ROUTER_R0024] The "next" callback was called more than once in one navigation guard when going from "/" to "/mypage". This will fail in production.
├▶ fix: Call `next()` exactly once per guard: remove the extra call, or migrate to returning the value you passed to `next()`.
╰▶ see: https://router.vuejs.org/guide/advanced/navigation-guards.html#Optional-third-argument-next
[VUE_ROUTER_R0025] The `next()` callback in navigation guards is deprecated.
├▶ fix: Return the value instead: `next()` becomes `return`, `next(false)` becomes `return false`, `next("/path")` becomes `return "/path"`.
╰▶ see: https://router.vuejs.org/guide/advanced/navigation-guards.html#Optional-third-argument-next
現在のURL: /login
=== B: next を受け取るが呼ばない ===
push: 200ms待っても完了しない / 現在のURL: /
=== C: return で書く ===
現在のURL: /login

A の例は、next('/login') の後に return が無いため、続けて next() も実行されます。vue-router 5.3.1 では2種類の警告が出ました。

警告コード 内容
VUE_ROUTER_R0025 next コールバックは非推奨。値を return する形に書き換える
VUE_ROUTER_R0024 1つのガードの中で next が2回以上呼ばれた。本番ビルドではうまく動かなくなる

B の例は、next を引数に書いたのに呼ばない分岐がある場合です。移動が完了せず、URL も変わりません。エラーも出ないので、原因を探すのに時間がかかりやすい形です。

C のように return で書けば、どちらも起きません。 書き換えは機械的にできます。

next を使う書き方 return を使う書き方
next() return(何も返さない)
next(false) return false
next('/login') return '/login'
next({ name: 'login' }) return { name: 'login' }

非同期のガード:ログイン状態を問い合わせてから判定する

ページを再読み込みすると、Pinia のストアは初期状態に戻ります。Cookie でセッションを持つアプリでは、最初の移動のときに「いまログインしているか」をサーバーへ問い合わせてから判定する形になります。ガードは async 関数にでき、await している間、移動は待たされます。

次の例では、問い合わせに300ミリ秒かかる想定にして、経過時間をログに出しています。あわせて、入力途中の離脱を止める onBeforeRouteLeave も入れました。

// async.mjs
import './setup.mjs'
import { createApp, h } from 'vue'
import { createPinia, defineStore } from 'pinia'
import { createRouter, createMemoryHistory, RouterView, onBeforeRouteLeave } from 'vue-router'

const t0 = Date.now()
const log = (...a) => console.log(String(Date.now() - t0).padStart(4) + 'ms', ...a)

const useAuthStore = defineStore('auth', {
  state: () => ({ user: null, checked: false }),
  actions: {
    // 再読み込み直後に1回だけ、サーバーへログイン状態を問い合わせる想定
    async fetchUser() {
      log('  fetchUser 開始')
      await new Promise((r) => setTimeout(r, 300))
      this.user = { name: 'taro' }
      this.checked = true
      log('  fetchUser 完了')
    },
  },
})

const Edit = {
  setup() {
    onBeforeRouteLeave((to, from) => {
      if (globalThis.dirty) { log('  onBeforeRouteLeave: 入力途中なので false を返す'); return false }
    })
    return () => h('h1', 'Edit')
  },
}
const router = createRouter({
  history: createMemoryHistory(),
  routes: [
    { path: '/', component: { render: () => h('h1', 'Home') } },
    { path: '/mypage', component: { render: () => h('h1', 'MyPage') }, meta: { requiresAuth: true } },
    { path: '/edit', component: Edit },
  ],
})
router.beforeEach(async (to) => {
  const auth = useAuthStore()
  if (!auth.checked) await auth.fetchUser()
  if (to.meta.requiresAuth && !auth.user) return '/login'
})
router.afterEach((to, from, failure) => log('afterEach', to.fullPath, failure ? '(中止)' : ''))

const app = createApp({ render: () => h(RouterView) })
app.use(createPinia()); app.use(router)
log('最初の表示 /'); await router.isReady(); app.mount('#app')
log('表示:', document.body.textContent)
log('push /mypage(2回目は問い合わせなし)'); await router.push('/mypage')
log('表示:', document.body.textContent)
log('push /edit'); await router.push('/edit')
globalThis.dirty = true
log('push /(入力途中)'); await router.push('/'); log('現在のURL:', router.currentRoute.value.fullPath)
globalThis.dirty = false
log('push /(保存後)'); await router.push('/'); log('現在のURL:', router.currentRoute.value.fullPath)

実行結果(左端の経過時間は実行のたびに少し変わります):

  11ms 最初の表示 /
  15ms   fetchUser 開始
 316ms   fetchUser 完了
 317ms afterEach / 
 326ms 表示: Home
 326ms push /mypage(2回目は問い合わせなし)
 327ms afterEach /mypage 
 329ms 表示: MyPage
 329ms push /edit
 329ms afterEach /edit 
 330ms push /(入力途中)
 331ms   onBeforeRouteLeave: 入力途中なので false を返す
 331ms afterEach / (中止)
 331ms 現在のURL: /edit
 331ms push /(保存後)
 331ms afterEach / 
 332ms 現在のURL: /
  • 最初の移動では fetchUser の完了を待ってから afterEach が呼ばれ、その後で画面が出ています。
  • 2回目の /mypage への移動は、checked が true になっているため問い合わせをせず、すぐ完了しています。「確認済みかどうか」のフラグを持つと、移動のたびに通信する形を避けられます。
  • /edit では、dirty が true の間は onBeforeRouteLeave が false を返し、URL が /edit のままになっています。

RouteMeta の型を拡張する(TypeScript)

TypeScript では、何もしないと to.meta.title の型は unknown です。次のファイルを型チェックします。

npm i -D typescript
// before/router.ts
import { createRouter, createWebHistory } from 'vue-router'

const Page = { render: () => null }

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: Page, meta: { title: 'ホーム' } },
    { path: '/mypage', component: Page, meta: { title: 'マイページ', requiresAuth: 'yes' } },
    { path: '/admin', component: Page, meta: { title: '管理', requiresAuth: true, roles: ['admin'] } },
  ],
})

router.beforeEach((to) => {
  const title: string = to.meta.title
  const roles: string[] = to.meta.roles ?? []
  if (to.meta.requireAuth) return '/login'
  document.title = title + roles.join(',')
})

tsconfig.json は strict を true、moduleResolution を bundler、noEmit を true にしています。

npx tsc -p before --pretty false
before/router.ts(15,9): error TS2322: Type 'unknown' is not assignable to type 'string'.
before/router.ts(16,9): error TS2740: Type '{}' is missing the following properties from type 'string[]': length, pop, push, concat, and 29 more.

to.meta.title も to.meta.roles も型がわからないため、代入の行でエラーになります。一方で、9行目の requiresAuth: 'yes' という値の書き間違いは見つかっていません。

そこで、公式ドキュメント(Route Meta Fields の TypeScript の節)にある方法で、RouteMeta インターフェースを拡張します。同じ router.ts を after フォルダにコピーし、型定義ファイルを1つ足します。

// after/route-meta.d.ts
import 'vue-router'

export {}

declare module 'vue-router' {
  interface RouteMeta {
    title: string
    requiresAuth?: boolean
    roles?: string[]
  }
}
npx tsc -p after --pretty false
after/router.ts(9,65): error TS2322: Type 'string' is not assignable to type 'boolean | undefined'.

エラーが入れ替わりました。代入の2か所は型が付いて通るようになり、代わりに requiresAuth: 'yes' が「string は boolean に代入できない」と指摘されています。 ここを requiresAuth: true に直すと、tsc は何も出力せず終了コード0で終わりました。

1点、覚えておきたい動きがあります。router.ts の17行目は to.meta.requireAuth(s が抜けた書き間違い)ですが、型を拡張した後もエラーになっていません。RouteMeta は任意のキーを受け付ける定義になっていて、宣言していないキーは unknown として扱われるためです。キー名の書き間違いは型チェックでは見つからないので、meta のキーは定数やヘルパー関数にまとめておくと安心です。

どのガードを使うかの判断表

やりたいこと 使うガード 理由
未ログインならログイン画面へ送る router.beforeEach 全ルートに1か所で効き、最初のほうで判定できる
特定のルートだけパラメータを検査する ルート定義の beforeEnter ほかのルートに影響しない
入力途中でページを離れるのを止める onBeforeRouteLeave そのコンポーネントの状態を見て判定できる
/users/1 → /users/2 でデータを取り直す onBeforeRouteUpdate コンポーネントが再利用され、mounted が呼ばれないため
すべてのガード通過後に処理をしたい router.beforeResolve コンポーネント内ガードと非同期読み込みの後に呼ばれる
ページタイトルを変える・アクセスを記録する router.afterEach 確定後に呼ばれる。failure で中止も見分けられる

beforeEnter は、実行結果で見たとおり同じルートのパラメータだけが変わるときには呼ばれません。 パラメータが変わるたびに検査したい条件は、beforeEach か onBeforeRouteUpdate に書きます。

つまずきやすい点のチェックリスト

症状 原因 直し方
Infinite redirect in navigation guard が出る リダイレクト先でも同じ条件でリダイレクトしている 行き先がログイン画面のときは通す。meta で対象を絞る
画面が切り替わらず、エラーも出ない next を引数に取ったのに呼ばない分岐がある next をやめて return で書く
The "next" callback was called more than once の警告 next の後に return が無い return で書く。next を使うなら return next(...)
"getActivePinia()" was called but there was no active Pinia ガードの外(モジュール直下)でストアを取得している useAuthStore() をガードの中で呼ぶ
パラメータを変えても beforeEnter が動かない 同じルート内の移動では呼ばれない beforeEach か onBeforeRouteUpdate に移す

よくある質問

Q. beforeEach と beforeResolve はどう使い分けますか? A. beforeEach は移動の最初のほうで呼ばれ、beforeResolve はコンポーネント内ガードと非同期コンポーネントの読み込みが終わった後、確定の直前に呼ばれます。認証のように早く判定したいものは beforeEach、すべての条件を通過した移動だけに行いたい処理は beforeResolve が合います。

Q. afterEach でリダイレクトできますか? A. afterEach は移動が確定した後に呼ばれるフックで、戻り値で移動を変えることはできません。ページタイトルの変更や記録に使います。第3引数の failure を見ると、中止された移動かどうかがわかります。

まとめ

  • 認証の確認は router.beforeEach に書く。 ルート単位・コンポーネント内のガードより前に判定できる。
  • 実行順は beforeEach → beforeEnter → beforeRouteEnter → beforeResolve → afterEach。 離れるページの beforeRouteLeave は beforeEach より前、mounted は afterEach より後。
  • 戻り値は「何も返さない=通す」「false=中止」「パスやオブジェクト=リダイレクト」。 next ではなく return で書く。
  • meta: { requiresAuth: true } は親ルートに書けば子に引き継がれる。 判定は to.meta.requiresAuth で足りる。
  • redirect クエリには to.fullPath を入れ、戻すときはアプリ内のパスだけ通す。
  • 無限リダイレクトは、リダイレクト先のルートを条件から外すと直る。
  • RouteMeta を拡張すると、meta の値の型違いに気づける。 キー名の書き間違いは検出されないので定数にまとめる。

参考・出典

  • Vue Router 公式ドキュメント「Navigation Guards」 https://router.vuejs.org/guide/advanced/navigation-guards.html
  • Vue Router 公式ドキュメント「Route Meta Fields」 https://router.vuejs.org/guide/advanced/meta.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vue Routerナビゲーションガード|beforeEachで認証リダイレクト」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?