Vue Router Configuration

Vue Router 4 is the official routing layer for Vue 3 applications, and getting its configuration right determines whether your single-page app produces crawlable URLs, guards protected views correctly, and ships a lean initial bundle. This guide walks through the full configuration surface — the history driver, route records, navigation guards, lazy loading, scroll behaviour, and transitions — for engineers building a production Vue SPA who need each piece wired together correctly rather than copied in isolation. It targets Vue 3.4+ and Vue Router 4.3+, both of which rely on ES2020+ syntax, Proxy-based reactivity, and the native browser History API.

← Back to Framework-Specific Routing Patterns

The Problem

A misconfigured router fails quietly until it fails loudly in production. Choose createWebHashHistory and your URLs grow a # fragment that search crawlers strip before indexing, so deep pages never surface in results. Choose createWebHistory without a server rewrite rule and every page refresh or pasted deep link returns a 404, because the static host looks for a file that does not exist. Skip lazy loading and the browser parses the entire application’s JavaScript before painting the first route, inflating First Contentful Paint and Largest Contentful Paint. Mix the legacy next() callback with the modern return-based guard signature and a single guard can fire navigation twice. Forget scrollBehavior and back/forward navigation lands the user mid-page instead of where they left off.

Each of these is a configuration decision made once at setup and felt across every navigation. The rest of this page treats them as a connected system: the same patterns underpin React Router implementation and SvelteKit routing conventions, but Vue Router 4 exposes them through its own composable, return-based API. The trap is that none of these failures appear in local development with the dev server, which transparently rewrites unmatched paths and serves every chunk eagerly. They surface only after a production build hits a real static host, which is why configuring the router correctly up front saves a debugging session against an environment you cannot easily reproduce on your machine.

Core API & Primitives

The router is assembled from a small set of factory functions and typed records. The history driver wraps the History API & state management layer so you rarely call pushState directly.

// vue-router v4.3 — core type signatures
import {
  createRouter,
  createWebHistory,
  type Router,
  type RouteRecordRaw,
  type NavigationGuardWithThis,
  type RouterScrollBehavior
} from 'vue-router'

// createRouter(options) returns the Router instance you install on the app
declare function createRouter(options: {
  history: ReturnType<typeof createWebHistory>
  routes: readonly RouteRecordRaw[]
  scrollBehavior?: RouterScrollBehavior
  strict?: boolean
  sensitive?: boolean
}): Router

// A route record: path + the component to render, plus optional metadata
interface ExampleRecord extends RouteRecordRaw {
  path: string
  name?: string
  component: () => Promise<unknown> // dynamic import = lazy chunk
  meta?: { requiresAuth?: boolean; keepAlive?: boolean }
  beforeEnter?: NavigationGuardWithThis<undefined>
  children?: RouteRecordRaw[]
}

The primitives you configure are:

  • createWebHistory(base?) — HTML5 History API mode producing clean paths (/dashboard/settings). The required driver for SEO, since it pushes real URLs onto the history stack that crawlers fetch and index.
  • createWebHashHistory(base?) — fragment mode (/#/dashboard/settings); use only when you cannot control server rewrites, because the part after the # never reaches the server and is discarded by most crawlers.
  • routes — an array of RouteRecordRaw, each mapping a path (static, dynamic :id, or catch-all) to a component.
  • Navigation guards — router.beforeEach (global), beforeEnter (per-route), and the composables onBeforeRouteLeave / onBeforeRouteUpdate (in-component).
  • scrollBehavior — a function returning the viewport position for each navigation.

A key behavioural difference from earlier routing libraries is that Vue Router 4’s guards are resolved through promises. Returning a Promise from beforeEach pauses navigation until it settles, which is what lets the async data check in Step 3 abort cleanly. The router also normalises every navigation into a typed route location object exposing path, params, query, hash, name, meta, and fullPath, so guard logic and components read the same immutable snapshot rather than parsing the URL by hand. Understanding which of these fields are reactive matters: inside a component, the useRoute() composable returns a reactive route object, so a watch on route.params.id reruns when only the parameter changes and the component instance is reused.

Step-by-Step Implementation

Prerequisite: a Vue 3.4+ project scaffolded with Vite, with vue-router@^4.3 installed and a src/views/ directory for route components.

Step 1: Create the router with HTML5 history

Start with createWebHistory so URLs stay clean and indexable. Pass the deploy base path from the build environment so the same code works under a sub-path.

// router/index.ts — vue-router v4.3
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  { path: '/', name: 'Home', component: () => import('../views/Home.vue') }
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL || '/'),
  routes,
  strict: true,    // treat /about and /about/ as distinct, no silent normalisation
  sensitive: false // case-insensitive path matching
})

export default router

Mount it once in your entry file: app.use(router) before app.mount('#app').

Step 2: Define dynamic and catch-all routes with lazy components

Use a dynamic segment to capture a parameter, an inline regex to constrain its shape, and a catch-all record so unmatched URLs render a 404 view instead of a blank screen. This mirrors the broader patterns in dynamic route segments and fallback routing strategies.

// router/routes.ts — vue-router v4.3
import type { RouteRecordRaw } from 'vue-router'

export const routes: RouteRecordRaw[] = [
  { path: '/', name: 'Home', component: () => import('../views/Home.vue') },
  {
    // exactly six digits — malformed IDs never reach the component
    path: '/products/:id(\\d{6})',
    name: 'ProductDetail',
    component: () => import('../views/ProductDetail.vue'), // own lazy chunk
    meta: { requiresAuth: true, keepAlive: true }
  },
  {
    // the trailing * makes pathMatch an array, useful for nested 404s
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import('../views/NotFound.vue')
  }
]

Step 3: Add navigation guards with the return-based signature

In Vue Router 4 a guard returns a value: false aborts, a route location object redirects, and undefined (or no return) proceeds. Do not call the legacy next() alongside a return — pick one style per guard.

// router/guards.ts — vue-router v4.3
import router from './index'
import { useAuthStore } from '../stores/auth'

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

  // Redirect unauthenticated users, preserving their intended destination
  if (to.meta.requiresAuth && !auth.isAuthenticated) {
    return { name: 'Login', query: { redirect: to.fullPath } }
  }

  // Async data check with cancellation if the user navigates away mid-flight
  if (to.name === 'ProductDetail') {
    const controller = new AbortController()
    try {
      const res = await fetch(`/api/products/${to.params.id}`, {
        signal: controller.signal
      })
      if (!res.ok) return { name: 'NotFound' }
    } catch (err: unknown) {
      // AbortError is expected on rapid navigation — ignore it
      if (err instanceof Error && err.name !== 'AbortError') {
        return { name: 'ServerError' }
      }
    }
  }
  // fall through → undefined → navigation proceeds
})

Step 4: Configure scroll behaviour

Client-side navigation bypasses the browser’s native scroll restoration, so supply scrollBehavior to restore saved positions on back/forward and to scroll smoothly to hash anchors. This complements the deeper techniques in scroll restoration strategies.

// router/scroll.ts — vue-router v4.3
import type { RouterScrollBehavior } from 'vue-router'

export const scrollBehavior: RouterScrollBehavior = (to, _from, savedPosition) => {
  if (savedPosition) return savedPosition          // restore on back/forward
  if (to.hash) return { el: to.hash, behavior: 'smooth' } // jump to anchor
  return { top: 0 }                                // reset on a fresh navigation
}

Pass it to createRouter next to history and routes.

Step 5: Wrap the view in a route transition

Use the <RouterView> slot with a <Transition> to animate between routes. Keyed on the route path, it cross-fades without re-mounting unrelated DOM.

<!-- App.vue — vue-router v4.3, Vue 3.4 -->
<template>
  <RouterView v-slot="{ Component, route }">
    <Transition name="fade" mode="out-in">
      <component :is="Component" :key="route.path" />
    </Transition>
  </RouterView>
</template>

<style>
.fade-enter-active,
.fade-leave-active { transition: opacity 0.18s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
</style>

Verification & Testing

Confirm that direct deep-link access works (the server fallback is in place), that guards redirect, and that lazy chunks load on demand. A Playwright test exercises all three:

// tests/router.spec.ts — @playwright/test v1.44
import { test, expect } from '@playwright/test'

test('deep link to a guarded route redirects when logged out', async ({ page }) => {
  // Direct navigation, not in-app — proves the server returns index.html
  await page.goto('/products/123456')
  await expect(page).toHaveURL(/\/login\?redirect=/)
})

test('product chunk loads only when its route is visited', async ({ page }) => {
  const chunkRequests: string[] = []
  page.on('request', (r) => {
    if (/ProductDetail.*\.js$/.test(r.url())) chunkRequests.push(r.url())
  })
  await page.goto('/')
  expect(chunkRequests).toHaveLength(0) // not loaded on the home route
  await page.getByRole('link', { name: 'View product' }).click()
  await expect.poll(() => chunkRequests.length).toBeGreaterThan(0)
})

In the browser, open the Vue Devtools Router panel to inspect the navigation stack and guard execution order, or watch the Network tab filtered to JS to confirm each route pulls its own chunk on first visit. The guard execution order is worth confirming explicitly: global beforeEach guards run first in registration order, then per-route beforeEnter, then in-component guards. A test that asserts a redirect proves the chain short-circuits at the right point rather than running every guard before bailing out.

Performance Tuning

  • Route-level code splitting. Every component: () => import(...) becomes a separate chunk. Under Vite this is automatic; this is the single largest lever on initial bundle size and therefore FCP and LCP.
  • Group vendor chunks. Split node_modules into a stable vendor chunk so repeat visitors hit the cache instead of redownloading framework code on every deploy.
// vite.config.ts — vite v5.x
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            return id.toString().split('node_modules/')[1].split('/')[0]
          }
        }
      }
    }
  }
})
  • Prefetch high-probability routes. Inject <link rel="prefetch"> for the route a user is most likely to visit next (for example, the product list from the home page), but avoid prefetching everything — aggressive preloading competes with the critical render path.
  • Use <keep-alive> deliberately. Caching a heavy component avoids re-render cost on return visits, but each cached instance holds its DOM and reactive state in memory. Watch the heap in long-lived sessions.
  • Keep guards off the main thread’s critical path. A guard that awaits a large synchronous computation delays every navigation; fetch over the network with AbortController instead so the main thread stays responsive and INP stays low.

Gotchas & Failure Modes

  • createWebHistory without a server fallback returns 404s. Static hosts must rewrite all non-asset paths to index.html; without it, refreshes and pasted deep links break even though in-app navigation works.
  • Mixing next() with a returned value double-fires navigation. The legacy callback still functions, but combining it with return in one guard invokes the resolution twice. Choose one style per guard.
  • strict: true surprises on trailing slashes. With strict matching, /about and /about/ are distinct routes; a link to the wrong one falls through to your catch-all 404.
  • Reused components do not re-run setup on param change. Navigating from /products/111111 to /products/222222 keeps the same component instance; watch route.params or use onBeforeRouteUpdate to refetch.
  • Unsanitised route.params and route.query are an XSS vector. Validate and escape parameter values before injecting them into the DOM.
  • <Transition> without mode="out-in" overlaps views. Both the leaving and entering components animate simultaneously, causing a visible flash or layout jump during the overlap.

Go Deeper

  • Nested Routes in Vue Router 4 — build modular layout trees with children and nested <RouterView> outlets so shared chrome renders once around swappable inner views.