Back to blog
Frontend Engineeringintermediate

Vue Router 4: Navigation, Guards, Lazy Loading & Meta Fields

Build multi-page Vue 3 apps with Vue Router 4 — define routes, use dynamic segments, implement navigation guards for auth, lazy-load route components, use route meta fields, and handle nested layouts.

LearnixoApril 16, 20265 min read
Vue.jsVue RouterVue 3SPANavigationTypeScriptFrontend
Share:𝕏

Installation & Setup

Bash
npm install vue-router@4
TYPESCRIPT
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) return savedPosition        // browser back/forward
    if (to.hash) return { el: to.hash, behavior: 'smooth' }
    return { top: 0 }
  },
})

export default router
TYPESCRIPT
// src/main.ts
import { createApp } from 'vue'
import router from './router'
app.use(router)

Route Definitions

TYPESCRIPT
// src/router/routes.ts
import type { RouteRecordRaw } from 'vue-router'

export const routes: RouteRecordRaw[] = [
  // Public routes
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/pages/Login.vue'),
    meta: { requiresAuth: false, layout: 'blank' },
  },

  // Protected routes — nested under AppLayout
  {
    path: '/',
    component: () => import('@/layouts/AppLayout.vue'),
    meta: { requiresAuth: true },
    children: [
      {
        path: '',
        name: 'Dashboard',
        component: () => import('@/pages/Dashboard.vue'),
        meta: { title: 'Dashboard', icon: 'LayoutDashboard' },
      },
      {
        path: 'appointments',
        name: 'Appointments',
        component: () => import('@/pages/Appointments.vue'),
        meta: { title: 'Appointments' },
      },
      {
        path: 'appointments/:id',
        name: 'AppointmentDetail',
        component: () => import('@/pages/AppointmentDetail.vue'),
        props: true,   // passes :id as a prop to the component
      },
      {
        path: 'calls',
        children: [
          {
            path: '',
            name: 'CallAnalytics',
            component: () => import('@/pages/calls/CallAnalytics.vue'),
          },
          {
            path: ':callId/transcript',
            name: 'CallTranscript',
            component: () => import('@/pages/calls/CallTranscript.vue'),
            props: true,
          },
        ],
      },
      {
        path: 'admin',
        name: 'Admin',
        component: () => import('@/pages/Admin.vue'),
        meta: { requiresAdmin: true },
      },
    ],
  },

  // Catch-all 404
  {
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import('@/pages/NotFound.vue'),
  },
]

Typed Route Parameters

Define route types for TypeScript safety:

TYPESCRIPT
// src/types/router.d.ts
import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean
    requiresAdmin?: boolean
    title?: string
    icon?: string
    layout?: 'default' | 'blank'
  }
}

Access in components:

VUE
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'

const route  = useRoute()
const router = useRouter()

// Typed — route.params.id is string
const appointmentId = route.params.id as string

// Or with props: true on the route
const props = defineProps<{ id: string }>()

// Navigate programmatically
function goToAppointment(id: string) {
  router.push({ name: 'AppointmentDetail', params: { id } })
}

// With query params
function search(query: string) {
  router.push({ name: 'Appointments', query: { q: query, page: '1' } })
}

// Replace (no browser history entry)
router.replace({ name: 'Dashboard' })

// Go back
router.back()
</script>

Navigation Guards

Global Guard — Authentication

TYPESCRIPT
// src/router/index.ts

router.beforeEach(async (to, from) => {
  const auth = useAuthStore()

  // Initialize auth state on first load
  if (!auth.user) {
    await auth.initialize()
  }

  // Route requires auth, user not logged in
  if (to.meta.requiresAuth !== false && !auth.isAuthenticated) {
    return {
      name: 'Login',
      query: { redirect: to.fullPath },  // remember where they were going
    }
  }

  // Route requires admin, user is not admin
  if (to.meta.requiresAdmin && !auth.isAdmin) {
    return { name: 'Dashboard' }
  }

  // Already logged in, trying to visit /login
  if (to.name === 'Login' && auth.isAuthenticated) {
    return { name: 'Dashboard' }
  }
})

Per-Route Guard

TYPESCRIPT
{
  path: 'appointments/:id',
  component: () => import('@/pages/AppointmentDetail.vue'),
  beforeEnter: async (to) => {
    // Validate the ID exists before rendering
    const store = useAppointmentStore()
    const appt  = await store.fetchById(to.params.id as string)
    if (!appt) return { name: 'NotFound' }
  }
}

In-Component Guard

VUE
<script setup lang="ts">
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'

const hasUnsavedChanges = ref(false)

// Warn user before leaving with unsaved changes
onBeforeRouteLeave((to, from) => {
  if (hasUnsavedChanges.value) {
    const confirmed = window.confirm('You have unsaved changes. Leave anyway?')
    if (!confirmed) return false  // cancel navigation
  }
})

// When route params change on same component (e.g., /appointments/1 → /appointments/2)
onBeforeRouteUpdate(async (to, from) => {
  if (to.params.id !== from.params.id) {
    await store.fetchById(to.params.id as string)
  }
})
</script>

Redirect After Login

VUE
<!-- src/pages/Login.vue -->
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const route  = useRoute()
const router = useRouter()
const auth   = useAuthStore()

async function onSubmit() {
  await auth.login(email.value, password.value)

  // Redirect to the page they originally tried to visit
  const redirect = route.query.redirect as string | undefined
  router.push(redirect ?? { name: 'Dashboard' })
}
</script>

Nested Layouts

VUE
<!-- src/layouts/AppLayout.vue -->
<script setup lang="ts">
import { useRoute } from 'vue-router'
import Sidebar from '@/components/Sidebar.vue'
import Header  from '@/components/Header.vue'

const route = useRoute()
</script>

<template>
  <div class="flex min-h-screen">
    <Sidebar />
    <div class="flex-1 flex flex-col">
      <Header :title="route.meta.title" />
      <main class="flex-1 p-6">
        <!-- Child routes render here -->
        <RouterView />
      </main>
    </div>
  </div>
</template>

Dynamic Page Titles

TYPESCRIPT
router.afterEach((to) => {
  const base  = 'Learnixo Portal'
  const title = to.meta.title
  document.title = title ? `${title}${base}` : base
})

useRouter Composable for Navigation

TYPESCRIPT
// src/composables/useNavigation.ts
import { useRouter } from 'vue-router'

export function useNavigation() {
  const router = useRouter()

  return {
    toAppointment:  (id: string)   => router.push({ name: 'AppointmentDetail', params: { id } }),
    toCallTranscript: (callId: string) => router.push({ name: 'CallTranscript', params: { callId } }),
    toDashboard:    ()             => router.push({ name: 'Dashboard' }),
    toLogin:        (redirect?: string) => router.push({
      name: 'Login',
      query: redirect ? { redirect } : undefined,
    }),
  }
}

Enjoyed this article?

Explore the Frontend Engineering learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.