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 ofRouteRecordRaw, each mapping a path (static, dynamic:id, or catch-all) to a component.- Navigation guards —
router.beforeEach(global),beforeEnter(per-route), and the composablesonBeforeRouteLeave/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_modulesinto 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
AbortControllerinstead so the main thread stays responsive and INP stays low.
Gotchas & Failure Modes
createWebHistorywithout a server fallback returns 404s. Static hosts must rewrite all non-asset paths toindex.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 withreturnin one guard invokes the resolution twice. Choose one style per guard. strict: truesurprises on trailing slashes. With strict matching,/aboutand/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/111111to/products/222222keeps the same component instance; watchroute.paramsor useonBeforeRouteUpdateto refetch. - Unsanitised
route.paramsandroute.queryare an XSS vector. Validate and escape parameter values before injecting them into the DOM. <Transition>withoutmode="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
childrenand nested<RouterView>outlets so shared chrome renders once around swappable inner views.
Related
- Framework-Specific Routing Patterns — the overview tying together routing approaches across Vue, React, Next.js, and SvelteKit.
- React Router Implementation — the equivalent guard, lazy-loading, and nested-route patterns in React’s data router.
- SvelteKit Routing Conventions — how SvelteKit’s filesystem routing handles the same concerns through directory structure.
- Nested Routes in Vue Router 4 — a focused walkthrough of child routes and nested outlets.